diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 4af01653..b1638403 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -25,19 +25,22 @@ export async function getAdminMenus( const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 + const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가 logger.info(`사용자 ID: ${userId}`); logger.info(`사용자 회사 코드: ${userCompanyCode}`); logger.info(`사용자 유형: ${userType}`); logger.info(`사용자 로케일: ${userLang}`); logger.info(`메뉴 타입: ${menuType || "전체"}`); + logger.info(`비활성 메뉴 포함: ${includeInactive}`); const paramMap = { userId, userCompanyCode, userType, userLang, - menuType, // menuType 추가 + menuType, // includeInactive와 관계없이 menuType 유지 (관리자/사용자 구분) + includeInactive, // includeInactive 추가 }; const menuList = await AdminService.getAdminMenuList(paramMap); @@ -1081,9 +1084,41 @@ export async function saveMenu( return; } + const userCompanyCode = req.user.companyCode; + const userType = req.user.userType; + let requestCompanyCode = menuData.companyCode || menuData.company_code; + + // "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용 + if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) { + requestCompanyCode = undefined; + } + + // 공통 메뉴(company_code = '*')는 최고 관리자만 생성 가능 + if (requestCompanyCode === "*") { + if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") { + res.status(403).json({ + success: false, + message: "공통 메뉴는 최고 관리자만 생성할 수 있습니다.", + error: "Unauthorized to create common menu", + }); + return; + } + } else if (userCompanyCode !== "*") { + // 회사 관리자는 자기 회사 메뉴만 생성 가능 + // requestCompanyCode가 undefined면 사용자 회사 코드 사용 (권한 체크 통과) + if (requestCompanyCode && requestCompanyCode !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 메뉴를 생성할 권한이 없습니다.", + error: "Unauthorized to create menu for this company", + }); + return; + } + } + // Raw Query를 사용한 메뉴 저장 const objid = Date.now(); // 고유 ID 생성 - const companyCode = req.user.companyCode; + const companyCode = requestCompanyCode || userCompanyCode; const [savedMenu] = await query( `INSERT INTO menu_info ( @@ -1164,7 +1199,73 @@ export async function updateMenu( return; } - const companyCode = req.user.companyCode; + const userCompanyCode = req.user.companyCode; + const userType = req.user.userType; + + // 수정하려는 메뉴 조회 + const currentMenu = await queryOne( + `SELECT objid, company_code 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 update common menu", + }); + return; + } + } else if (userCompanyCode !== "*") { + // 회사 관리자는 자기 회사 메뉴만 수정 가능 + if (currentMenu.company_code !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 메뉴를 수정할 권한이 없습니다.", + error: "Unauthorized to update menu for this company", + }); + return; + } + } + + const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code; + + // company_code 변경 시도하는 경우 권한 체크 + if (requestCompanyCode !== currentMenu.company_code) { + // 공통 메뉴로 변경하려는 경우 최고 관리자만 가능 + if (requestCompanyCode === "*") { + if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") { + res.status(403).json({ + success: false, + message: "공통 메뉴로 변경할 권한이 없습니다.", + error: "Unauthorized to change to common menu", + }); + return; + } + } + // 회사 관리자는 자기 회사로만 변경 가능 + else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "해당 회사로 변경할 권한이 없습니다.", + error: "Unauthorized to change company", + }); + return; + } + } + + const companyCode = requestCompanyCode; // Raw Query를 사용한 메뉴 수정 const [updatedMenu] = await query( @@ -1239,6 +1340,56 @@ export async function deleteMenu( 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( + `SELECT objid, company_code 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; + } + } + // Raw Query를 사용한 메뉴 삭제 const [deletedMenu] = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, @@ -1292,6 +1443,51 @@ export async function deleteMenusBatch( return; } + // 사용자의 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; + + // 삭제하려는 메뉴들의 company_code 확인 + const menusToDelete = await query( + `SELECT objid, company_code FROM menu_info WHERE objid = ANY($1::bigint[])`, + [menuIds.map((id) => Number(id))] + ); + + // 권한 체크: 공통 메뉴 포함 여부 확인 + const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*"); + if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) { + res.status(403).json({ + success: false, + message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.", + error: "Unauthorized to delete common menu", + }); + return; + } + + // 회사 관리자는 자기 회사 메뉴만 삭제 가능 + if (userCompanyCode !== "*") { + const unauthorizedMenus = menusToDelete.filter( + (menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*" + ); + if (unauthorizedMenus.length > 0) { + res.status(403).json({ + success: false, + message: "다른 회사의 메뉴를 삭제할 권한이 없습니다.", + error: "Unauthorized to delete menus for other companies", + }); + return; + } + } + // Raw Query를 사용한 메뉴 일괄 삭제 let deletedCount = 0; let failedCount = 0; @@ -1354,6 +1550,103 @@ export async function deleteMenusBatch( } } +/** + * 메뉴 활성/비활성 토글 + */ +export async function toggleMenuStatus( + req: AuthenticatedRequest, + res: Response +): Promise { + 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( + `SELECT objid, status, company_code 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 toggle common menu status", + }); + return; + } + } else if (userCompanyCode !== "*") { + // 회사 관리자는 자기 회사 메뉴만 상태 변경 가능 + if (currentMenu.company_code !== userCompanyCode) { + res.status(403).json({ + success: false, + message: "해당 회사의 메뉴 상태를 변경할 권한이 없습니다.", + error: "Unauthorized to toggle menu status for this company", + }); + return; + } + } + + // 상태 토글 (active <-> inactive) + const currentStatus = currentMenu.status; + const newStatus = currentStatus === "active" ? "inactive" : "active"; + + // 상태 업데이트 + const [updatedMenu] = await query( + `UPDATE menu_info SET status = $1 WHERE objid = $2 RETURNING *`, + [newStatus, Number(menuId)] + ); + + logger.info("메뉴 상태 토글 성공", { + menuId, + oldStatus: currentStatus, + newStatus, + }); + + const result = newStatus === "active" ? "활성화" : "비활성화"; + + const response: ApiResponse = { + success: true, + message: `메뉴가 ${result}되었습니다.`, + data: result, + }; + + res.status(200).json(response); + } catch (error) { + logger.error("메뉴 상태 토글 실패:", error); + res.status(500).json({ + success: false, + message: "메뉴 상태 변경에 실패하였습니다.", + error: error instanceof Error ? error.message : "Unknown error", + errorCode: "MENU_TOGGLE_ERROR", + }); + } +} + /** * 회사 목록 조회 (실제 데이터베이스에서) */ diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index c6ae0bfc..ccca89b0 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -7,6 +7,7 @@ import { updateMenu, // 메뉴 수정 deleteMenu, // 메뉴 삭제 deleteMenusBatch, // 메뉴 일괄 삭제 + toggleMenuStatus, // 메뉴 상태 토글 getUserList, getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 @@ -37,6 +38,7 @@ router.get("/user-menus", getUserMenus); router.get("/menus/:menuId", getMenuInfo); router.post("/menus", saveMenu); // 메뉴 추가 router.put("/menus/:menuId", updateMenu); // 메뉴 수정 +router.put("/menus/:menuId/toggle", toggleMenuStatus); // 메뉴 상태 토글 router.delete("/menus/batch", deleteMenusBatch); // 메뉴 일괄 삭제 (순서 중요!) router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 4f2e926c..c6ab17c6 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -19,7 +19,15 @@ export class AdminService { // menuType에 따른 WHERE 조건 생성 const menuTypeCondition = - menuType !== undefined ? `MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; + menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1"; + + // 메뉴 관리 화면인지 좌측 사이드바인지 구분 + // includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면 + const includeInactive = paramMap.includeInactive === true; + const isManagementScreen = includeInactive || menuType === undefined; + // 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시 + const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'"; + const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'"; // 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만) let authFilter = ""; @@ -27,8 +35,8 @@ export class AdminService { let queryParams: any[] = [userLang]; let paramIndex = 2; - if (menuType !== undefined && userType !== "SUPER_ADMIN") { - // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우 + if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) { + // 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크 const userRoleGroups = await query( ` SELECT DISTINCT am.objid AS role_objid, am.auth_name @@ -123,7 +131,7 @@ export class AdminService { return []; } } - } else if (menuType !== undefined && userType === "SUPER_ADMIN") { + } else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) @@ -136,7 +144,7 @@ export class AdminService { // SUPER_ADMIN과 COMPANY_ADMIN 구분 if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // SUPER_ADMIN - if (menuType === undefined) { + if (isManagementScreen) { // 메뉴 관리 화면: 모든 메뉴 logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; @@ -145,16 +153,34 @@ export class AdminService { logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } - } else if (menuType === undefined) { - // 메뉴 관리 화면: 자기 회사 + 공통 메뉴 + } else if (isManagementScreen) { + // 메뉴 관리 화면: 회사별 필터링 + if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { + // 최고 관리자: 모든 메뉴 (공통 + 모든 회사) + logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + companyFilter = ""; + } else { + // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) + logger.info( + `✅ 메뉴 관리 화면 (${userType}): 회사 ${userCompanyCode} 메뉴만 표시 (공통 메뉴 제외)` + ); + companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`; + queryParams.push(userCompanyCode); + paramIndex++; + + // 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외) + if (unionFilter === "") { + unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`; + } + } + } else if (menuType !== undefined) { + // 좌측 사이드바: authFilter에서 이미 회사 필터링 포함 + // 회사 관리자는 좌측 사이드바에서도 자기 회사 메뉴 조회 가능 logger.info( - `✅ 메뉴 관리 화면: 회사 ${userCompanyCode} + 공통 메뉴 표시` + `✅ 좌측 사이드바: 회사 ${userCompanyCode} 메뉴 표시 (${userType})` ); - companyFilter = `AND (MENU.COMPANY_CODE = $${paramIndex} OR MENU.COMPANY_CODE = '*')`; - queryParams.push(userCompanyCode); - paramIndex++; + // companyFilter는 authFilter에서 이미 처리됨 } - // menuType이 정의된 경우 (좌측 사이드바)는 authFilter에서 이미 회사 필터링 포함 // 기존 Java의 selectAdminMenuList 쿼리를 Raw Query로 포팅 // WITH RECURSIVE 쿼리 구현 @@ -237,7 +263,7 @@ export class AdminService { ) FROM MENU_INFO MENU WHERE ${menuTypeCondition} - AND STATUS = 'active' + AND ${statusCondition} ${companyFilter} ${authFilter} AND NOT EXISTS ( @@ -304,7 +330,7 @@ export class AdminService { FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) - AND MENU_SUB.STATUS = 'active' + AND ${subStatusCondition} ${unionFilter} ) SELECT diff --git a/frontend/app/(main)/admin/batch-management/page.tsx b/frontend/app/(main)/admin/batch-management/page.tsx index ed762521..5bb82a84 100644 --- a/frontend/app/(main)/admin/batch-management/page.tsx +++ b/frontend/app/(main)/admin/batch-management/page.tsx @@ -353,14 +353,14 @@ export default function BatchManagementPage() { - 작업명 - 타입 - 스케줄 - 상태 - 실행 통계 - 성공률 - 마지막 실행 - 작업 + 작업명 + 타입 + 스케줄 + 상태 + 실행 통계 + 성공률 + 마지막 실행 + 작업 diff --git a/frontend/app/(main)/admin/collection-management/page.tsx b/frontend/app/(main)/admin/collection-management/page.tsx index 6523b1d0..75f00cdb 100644 --- a/frontend/app/(main)/admin/collection-management/page.tsx +++ b/frontend/app/(main)/admin/collection-management/page.tsx @@ -249,20 +249,20 @@ export default function CollectionManagementPage() {
- 설정명 - 수집 타입 - 소스 테이블 - 대상 테이블 - 스케줄 - 상태 - 마지막 수집 - 작업 + 설정명 + 수집 타입 + 소스 테이블 + 대상 테이블 + 스케줄 + 상태 + 마지막 수집 + 작업 {filteredConfigs.map((config) => ( - - + +
{config.config_name}
{config.description && ( @@ -272,27 +272,27 @@ export default function CollectionManagementPage() { )}
- + {getTypeBadge(config.collection_type)} - + {config.source_table} - + {config.target_table || "-"} - + {config.schedule_cron || "-"} - + {getStatusBadge(config.is_active)} - + {config.last_collected_at ? new Date(config.last_collected_at).toLocaleString() : "-"} - +
- - 연결명 - DB 타입 - 호스트:포트 - 데이터베이스 - 사용자 - 상태 - 생성일 - 연결 테스트 - 작업 + + 연결명 + DB 타입 + 호스트:포트 + 데이터베이스 + 사용자 + 상태 + 생성일 + 연결 테스트 + 작업 {connections.map((connection) => ( - - + +
{connection.connection_name}
- + {DB_TYPE_LABELS[connection.db_type] || connection.db_type} - + {connection.host}:{connection.port} - {connection.database_name} - {connection.username} - + {connection.database_name} + {connection.username} + {connection.is_active === "Y" ? "활성" : "비활성"} - + {connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"} - +
@@ -127,13 +127,13 @@ export default function WebTypesManagePage() { } return ( -
+
{/* 페이지 제목 */} -
+
-

웹타입 관리

-

화면관리에서 사용할 웹타입들을 관리합니다

+

웹타입 관리

+

화면관리에서 사용할 웹타입들을 관리합니다

- - - handleSort("sort_order")}> -
- 순서 - {sortField === "sort_order" && - (sortDirection === "asc" ? : )} -
-
- handleSort("web_type")}> -
- 웹타입 코드 - {sortField === "web_type" && - (sortDirection === "asc" ? : )} -
-
- handleSort("type_name")}> -
- 웹타입명 - {sortField === "type_name" && - (sortDirection === "asc" ? : )} -
-
- handleSort("category")}> -
- 카테고리 - {sortField === "category" && - (sortDirection === "asc" ? : )} -
-
- 설명 - handleSort("component_name")}> -
- 연결된 컴포넌트 - {sortField === "component_name" && - (sortDirection === "asc" ? : )} -
-
- handleSort("config_panel")}> -
- 설정 패널 - {sortField === "config_panel" && - (sortDirection === "asc" ? : )} -
-
- handleSort("is_active")}> -
- 상태 - {sortField === "is_active" && - (sortDirection === "asc" ? : )} -
-
- handleSort("updated_date")}> -
- 최종 수정일 - {sortField === "updated_date" && - (sortDirection === "asc" ? : )} -
-
- 작업 +
+
+ + + handleSort("sort_order")}> +
+ 순서 + {sortField === "sort_order" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("web_type")}> +
+ 웹타입 코드 + {sortField === "web_type" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("type_name")}> +
+ 웹타입명 + {sortField === "type_name" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("category")}> +
+ 카테고리 + {sortField === "category" && + (sortDirection === "asc" ? : )} +
+
+ 설명 + handleSort("component_name")}> +
+ 연결된 컴포넌트 + {sortField === "component_name" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("config_panel")}> +
+ 설정 패널 + {sortField === "config_panel" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("is_active")}> +
+ 상태 + {sortField === "is_active" && + (sortDirection === "asc" ? : )} +
+
+ handleSort("updated_date")}> +
+ 최종 수정일 + {sortField === "updated_date" && + (sortDirection === "asc" ? : )} +
+
+ 작업
@@ -279,38 +278,38 @@ export default function WebTypesManagePage() { ) : ( filteredAndSortedWebTypes.map((webType) => ( - - {webType.sort_order || 0} - {webType.web_type} - + + {webType.sort_order || 0} + {webType.web_type} + {webType.type_name} {webType.type_name_eng && (
{webType.type_name_eng}
)}
- + {webType.category} - {webType.description || "-"} - + {webType.description || "-"} + {webType.component_name || "TextWidget"} - + {webType.config_panel === "none" || !webType.config_panel ? "기본 설정" : webType.config_panel} - + {webType.is_active === "Y" ? "활성화" : "비활성화"} - + {webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"} - +
@@ -341,7 +340,7 @@ export default function WebTypesManagePage() { handleDelete(webType.web_type, webType.type_name)} disabled={isDeleting} - className="bg-red-600 hover:bg-red-700" + className="bg-destructive hover:bg-destructive/90" > {isDeleting ? "삭제 중..." : "삭제"} @@ -355,12 +354,11 @@ export default function WebTypesManagePage() { )}
- - + {deleteError && ( -
-

+

+

삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}

diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 8d6d33d0..353e487c 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Search, Database, RefreshCw, Settings, Plus, Activity, Trash2 } from "lucide-react"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; @@ -92,6 +93,9 @@ export default function TableManagementPage() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + + // 선택된 테이블 목록 (체크박스) + const [selectedTableIds, setSelectedTableIds] = useState>(new Set()); // 최고 관리자 여부 확인 (회사코드가 "*"인 경우) const isSuperAdmin = user?.companyCode === "*"; @@ -594,11 +598,91 @@ export default function TableManagementPage() { } }; + // 체크박스 선택 핸들러 + const handleTableCheck = (tableName: string, checked: boolean) => { + setSelectedTableIds((prev) => { + const newSet = new Set(prev); + if (checked) { + newSet.add(tableName); + } else { + newSet.delete(tableName); + } + return newSet; + }); + }; + + // 전체 선택/해제 + const handleSelectAll = (checked: boolean) => { + if (checked) { + const filteredTables = tables.filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + ); + setSelectedTableIds(new Set(filteredTables.map((table) => table.tableName))); + } else { + setSelectedTableIds(new Set()); + } + }; + + // 일괄 삭제 확인 + const handleBulkDeleteClick = () => { + if (selectedTableIds.size === 0) return; + setDeleteDialogOpen(true); + }; + + // 일괄 삭제 실행 + const handleBulkDelete = async () => { + if (selectedTableIds.size === 0) return; + + setIsDeleting(true); + try { + const tablesToDelete = Array.from(selectedTableIds); + let successCount = 0; + let failCount = 0; + + for (const tableName of tablesToDelete) { + try { + const result = await ddlApi.dropTable(tableName); + if (result.success) { + successCount++; + // 삭제된 테이블이 선택된 테이블이었다면 선택 해제 + if (selectedTable === tableName) { + setSelectedTable(null); + setColumns([]); + } + } else { + failCount++; + } + } catch (error) { + failCount++; + } + } + + if (successCount > 0) { + toast.success(`${successCount}개의 테이블이 성공적으로 삭제되었습니다.`); + } + if (failCount > 0) { + toast.error(`${failCount}개의 테이블 삭제에 실패했습니다.`); + } + + // 선택 초기화 및 테이블 목록 새로고침 + setSelectedTableIds(new Set()); + await loadTables(); + } catch (error: any) { + toast.error("테이블 삭제 중 오류가 발생했습니다."); + } finally { + setIsDeleting(false); + setDeleteDialogOpen(false); + setTableToDelete(""); + } + }; + return ( -
-
+
+
{/* 페이지 헤더 */} -
+

@@ -664,28 +748,65 @@ export default function TableManagementPage() {

-
+
{/* 좌측 사이드바: 테이블 목록 (20%) */} -
-
-

- - {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")} -

- +
+
{/* 검색 */} -
- - setSearchTerm(e.target.value)} - className="h-10 pl-10 text-sm" - /> +
+
+ + setSearchTerm(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
{/* 테이블 목록 */} -
+
+ {/* 전체 선택 및 일괄 삭제 (최고 관리자만) */} + {isSuperAdmin && ( +
+
+ + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + ).length > 0 && + tables + .filter( + (table) => + table.tableName.toLowerCase().includes(searchTerm.toLowerCase()) || + (table.displayName && table.displayName.toLowerCase().includes(searchTerm.toLowerCase())), + ) + .every((table) => selectedTableIds.has(table.tableName)) + } + onCheckedChange={handleSelectAll} + aria-label="전체 선택" + /> + + {selectedTableIds.size > 0 && `${selectedTableIds.size}개 선택됨`} + +
+ {selectedTableIds.size > 0 && ( + + )} +
+ )} + {loading ? (
@@ -707,40 +828,44 @@ export default function TableManagementPage() { .map((table) => (
-
handleTableSelect(table.tableName)}> -

{table.displayName || table.tableName}

-

- {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} -

-
- 컬럼 - - {table.columnCount} - +
+ {/* 체크박스 (최고 관리자만) */} + {isSuperAdmin && ( + handleTableCheck(table.tableName, checked as boolean)} + aria-label={`${table.displayName || table.tableName} 선택`} + className="mt-0.5" + onClick={(e) => e.stopPropagation()} + /> + )} +
handleTableSelect(table.tableName)} + > +

{table.displayName || table.tableName}

+

+ {table.description || getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_DESCRIPTION, "설명 없음")} +

+
+ 컬럼 + + {table.columnCount} + +
- - {/* 삭제 버튼 (최고 관리자만) */} - {isSuperAdmin && ( -
- -
- )}
)) )} @@ -749,16 +874,11 @@ export default function TableManagementPage() {
{/* 우측 메인 영역: 컬럼 타입 관리 (80%) */} -
-
-

- - {selectedTable ? <>테이블 설정 - {selectedTable} : "테이블 타입 관리"} -

- -
+
+
+
{!selectedTable ? ( -
+

{getTextFromUI(TABLE_MANAGEMENT_KEYS.SELECT_TABLE_PLACEHOLDER, "테이블을 선택해주세요")} @@ -801,19 +921,19 @@ export default function TableManagementPage() { ) : (

{/* 컬럼 헤더 */} -
-
컬럼명
+
+
컬럼명
라벨
-
입력 타입
-
+
입력 타입
+
상세 설정
-
설명
+
설명
{/* 컬럼 리스트 */}
{ const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; // 스크롤이 끝에 가까워지면 더 많은 데이터 로드 @@ -825,9 +945,9 @@ export default function TableManagementPage() { {columns.map((column, index) => (
-
+
{column.columnName}
@@ -838,7 +958,7 @@ export default function TableManagementPage() { className="h-8 text-xs" />
-
+
-
+
{/* 웹 타입이 'code'인 경우 공통코드 선택 */} {column.inputType === "code" && ( + handleDetailSettingsChange(column.columnName, "entity", value) + } + > + + + + + {referenceTableOptions.map((option, index) => ( + +
+ {option.label} + + {option.value} + +
+
+ ))} +
+
-
- {/* 참조 테이블 */} + {/* 조인 컬럼 */} + {column.referenceTable && column.referenceTable !== "none" && (
+ )} - {/* 조인 컬럼 */} - {column.referenceTable && column.referenceTable !== "none" && ( + {/* 표시 컬럼 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && (
)} -
- - {/* 설정 완료 표시 - 간소화 */} - {column.referenceTable && - column.referenceTable !== "none" && - column.referenceColumn && - column.referenceColumn !== "none" && - column.displayColumn && - column.displayColumn !== "none" && ( -
- - - {column.columnName} → {column.referenceTable}.{column.displayColumn} - -
- )}
+ + {/* 설정 완료 표시 */} + {column.referenceTable && + column.referenceTable !== "none" && + column.referenceColumn && + column.referenceColumn !== "none" && + column.displayColumn && + column.displayColumn !== "none" && ( +
+ + + {column.columnName} → {column.referenceTable}.{column.displayColumn} + +
+ )}
)} {/* 다른 웹 타입인 경우 빈 공간 */} {column.inputType !== "code" && column.inputType !== "entity" && ( -
-
+
+ - +
)}
-
+
handleColumnChange(index, "description", e.target.value)} @@ -1075,26 +1236,62 @@ export default function TableManagementPage() { - 테이블 삭제 확인 + + {selectedTableIds.size > 0 ? "테이블 일괄 삭제 확인" : "테이블 삭제 확인"} + - 정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + {selectedTableIds.size > 0 ? ( + <> + 선택된 {selectedTableIds.size}개의 테이블을 삭제하시겠습니까? +
+ 이 작업은 되돌릴 수 없습니다. + + ) : ( + <> + 정말로 테이블을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다. + + )}
-
-
-

경고

-

- 테이블 {tableToDelete}과 모든 데이터가 영구적으로 - 삭제됩니다. -

+ {selectedTableIds.size === 0 && tableToDelete && ( +
+
+

경고

+

+ 테이블 {tableToDelete}과 모든 데이터가 영구적으로 + 삭제됩니다. +

+
-
+ )} + + {selectedTableIds.size > 0 && ( +
+
+

경고

+

+ 다음 테이블들과 모든 데이터가 영구적으로 삭제됩니다: +

+
    + {Array.from(selectedTableIds).map((tableName) => ( +
  • + {tableName} +
  • + ))} +
+
+
+ )}
); }; @@ -423,12 +420,12 @@ export const RepeaterInput: React.FC = ({
{/* 드래그 핸들 */} {allowReorder && !readonly && !disabled && ( - + )} {/* 인덱스 번호 */} {showIndex && ( - 항목 {itemIndex + 1} + 항목 {itemIndex + 1} )}
@@ -453,7 +450,7 @@ export const RepeaterInput: React.FC = ({ variant="ghost" size="icon" onClick={() => handleRemoveItem(itemIndex)} - className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-700" + className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive" title="항목 제거" > @@ -467,9 +464,9 @@ export const RepeaterInput: React.FC = ({
{fields.map((field) => (
-
@@ -500,7 +497,7 @@ export const RepeaterInput: React.FC = ({ )} {/* 제한 안내 */} -
+
현재: {items.length}개 항목 (최소: {minItems}, 최대: {maxItems}) diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx index 8114877d..1ced8546 100644 --- a/frontend/contexts/MenuContext.tsx +++ b/frontend/contexts/MenuContext.tsx @@ -71,6 +71,7 @@ export function MenuProvider({ children }: { children: ReactNode }) { } // 관리자 메뉴와 사용자 메뉴를 병렬로 로드 + // 좌측 사이드바용: active만 표시 const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); if (adminResponse.success && adminResponse.data) { diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 5a0c5972..80156357 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -76,7 +76,7 @@ export interface ApiResponse { } export const menuApi = { - // 관리자 메뉴 목록 조회 + // 관리자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getAdminMenus: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "0" } }); if (response.data.success && response.data.data && response.data.data.length > 0) { @@ -84,12 +84,24 @@ export const menuApi = { return response.data; }, - // 사용자 메뉴 목록 조회 + // 사용자 메뉴 목록 조회 (좌측 사이드바용 - active만 표시) getUserMenus: async (): Promise> => { const response = await apiClient.get("/admin/menus", { params: { menuType: "1" } }); return response.data; }, + // 관리자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) + getAdminMenusForManagement: async (): Promise> => { + const response = await apiClient.get("/admin/menus", { params: { menuType: "0", includeInactive: "true" } }); + return response.data; + }, + + // 사용자 메뉴 목록 조회 (메뉴 관리 화면용 - 모든 상태 표시) + getUserMenusForManagement: async (): Promise> => { + const response = await apiClient.get("/admin/menus", { params: { menuType: "1", includeInactive: "true" } }); + return response.data; + }, + // 메뉴 정보 조회 getMenuInfo: async (menuId: string): Promise> => { const response = await apiClient.get(`/admin/menus/${menuId}`);