From 36bff64145db18b6ba8241ddbb2f5754fb086833 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 13 Nov 2025 14:41:24 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=20=EB=A9=94=EB=89=B4=EB=B3=84=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - category_column_mapping 테이블 생성 (마이그레이션 054) - 테이블 타입 관리에서 2레벨 메뉴 선택 기능 추가 - 카테고리 컬럼 조회 시 현재 메뉴 및 상위 메뉴 매핑 자동 조회 - 캐시 무효화 로직 개선 - 메뉴별 카테고리 설정 저장 및 불러오기 기능 구현 --- .../tableCategoryValueController.ts | 30 +++ .../controllers/tableManagementController.ts | 133 ++++++++--- .../src/routes/tableCategoryValueRoutes.ts | 4 + .../src/services/screenManagementService.ts | 50 ++++ .../src/services/tableCategoryValueService.ts | 90 +++++++ .../src/services/tableManagementService.ts | 179 +++++++++++--- frontend/app/(main)/admin/tableMng/page.tsx | 223 +++++++++++++++++- .../AddCategoryColumnDialog.tsx | 139 ++++++++--- frontend/lib/api/tableCategoryValue.ts | 25 ++ 9 files changed, 779 insertions(+), 94 deletions(-) diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 6d0172c9..8bb2b0db 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -471,3 +471,33 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon } }; +/** + * 2레벨 메뉴 목록 조회 + * + * GET /api/categories/second-level-menus + * + * 카테고리 컬럼 매핑 생성 시 메뉴 선택용 + * 2레벨 메뉴를 선택하면 해당 메뉴의 모든 하위 메뉴에서 사용 가능 + */ +export const getSecondLevelMenus = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + + logger.info("2레벨 메뉴 목록 조회", { companyCode }); + + const menus = await tableCategoryValueService.getSecondLevelMenus(companyCode); + + return res.json({ + success: true, + data: menus, + }); + } catch (error: any) { + logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: "2레벨 메뉴 목록 조회 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 85159dc2..4f6af0b9 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -1657,37 +1657,108 @@ export async function getCategoryColumnsByMenu( return; } - // 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함) - logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode }); - - const columnsQuery = ` - SELECT - ttc.table_name AS "tableName", - COALESCE( - tl.table_label, - initcap(replace(ttc.table_name, '_', ' ')) - ) AS "tableLabel", - ttc.column_name AS "columnName", - COALESCE( - cl.column_label, - initcap(replace(ttc.column_name, '_', ' ')) - ) AS "columnLabel", - ttc.input_type AS "inputType" - FROM table_type_columns ttc - LEFT JOIN column_labels cl - ON ttc.table_name = cl.table_name - AND ttc.column_name = cl.column_name - LEFT JOIN table_labels tl - ON ttc.table_name = tl.table_name - WHERE ttc.table_name = ANY($1) - AND ttc.company_code = $2 - AND ttc.input_type = 'category' - ORDER BY ttc.table_name, ttc.column_name - `; - - logger.info("🔍 카테고리 컬럼 쿼리 실행 중..."); - const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); - logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length }); + // 3. category_column_mapping 테이블 존재 여부 확인 + const tableExistsResult = await pool.query(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'category_column_mapping' + ) as table_exists + `); + const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true; + + let columnsResult; + + if (mappingTableExists) { + // 🆕 category_column_mapping을 사용한 필터링 + logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); + + // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀) + const ancestorMenuQuery = ` + WITH RECURSIVE menu_hierarchy AS ( + -- 현재 메뉴 + SELECT objid, parent_obj_id, menu_type + FROM menu_info + WHERE objid = $1 + + UNION ALL + + -- 부모 메뉴 재귀 조회 + SELECT m.objid, m.parent_obj_id, m.menu_type + FROM menu_info m + INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id + WHERE m.parent_obj_id != 0 -- 최상위 메뉴(parent_obj_id=0) 제외 + ) + SELECT ARRAY_AGG(objid) as menu_objids + FROM menu_hierarchy + `; + + const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]); + const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)]; + + + const columnsQuery = ` + SELECT DISTINCT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ccm.logical_column_name AS "columnName", + COALESCE( + cl.column_label, + initcap(replace(ccm.logical_column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM category_column_mapping ccm + INNER JOIN table_type_columns ttc + ON ccm.table_name = ttc.table_name + AND ccm.physical_column_name = ttc.column_name + LEFT JOIN column_labels cl + ON ttc.table_name = cl.table_name + AND ttc.column_name = cl.column_name + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ccm.table_name = ANY($1) + AND ccm.company_code = $2 + AND ccm.menu_objid = ANY($3) + AND ttc.input_type = 'category' + ORDER BY ttc.table_name, ccm.logical_column_name + `; + + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]); + logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length }); + } else { + // 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회 + logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { tableNames, companyCode }); + + const columnsQuery = ` + SELECT + ttc.table_name AS "tableName", + COALESCE( + tl.table_label, + initcap(replace(ttc.table_name, '_', ' ')) + ) AS "tableLabel", + ttc.column_name AS "columnName", + COALESCE( + cl.column_label, + initcap(replace(ttc.column_name, '_', ' ')) + ) AS "columnLabel", + ttc.input_type AS "inputType" + FROM table_type_columns ttc + LEFT JOIN column_labels cl + ON ttc.table_name = cl.table_name + AND ttc.column_name = cl.column_name + LEFT JOIN table_labels tl + ON ttc.table_name = tl.table_name + WHERE ttc.table_name = ANY($1) + AND ttc.company_code = $2 + AND ttc.input_type = 'category' + ORDER BY ttc.table_name, ttc.column_name + `; + + columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); + logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length }); + } logger.info("✅ 카테고리 컬럼 조회 완료", { columnCount: columnsResult.rows.length diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index 436966e7..c4afe66e 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -11,6 +11,7 @@ import { createColumnMapping, getLogicalColumns, deleteColumnMapping, + getSecondLevelMenus, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -44,6 +45,9 @@ router.post("/values/reorder", reorderCategoryValues); // 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명) // ================================================ +// 2레벨 메뉴 목록 조회 (메뉴 선택용) +router.get("/second-level-menus", getSecondLevelMenus); + // 컬럼 매핑 조회 router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 8ee3b9a4..87ee4544 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1514,6 +1514,7 @@ export class ScreenManagementService { throw new Error("이미 할당된 화면입니다."); } + // screen_menu_assignments에 할당 추가 await query( `INSERT INTO screen_menu_assignments ( screen_id, menu_objid, company_code, display_order, created_by @@ -1526,6 +1527,40 @@ export class ScreenManagementService { assignmentData.createdBy || null, ] ); + + // 화면 정보 조회 (screen_code 가져오기) + const screen = await queryOne<{ screen_code: string }>( + `SELECT screen_code FROM screen_definitions WHERE screen_id = $1`, + [screenId] + ); + + if (screen) { + // menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정) + // 관리자 메뉴인지 확인 + const menu = await queryOne<{ menu_type: string }>( + `SELECT menu_type FROM menu_info WHERE objid = $1`, + [assignmentData.menuObjid] + ); + + const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin"); + const menuUrl = isAdminMenu + ? `/screens/${screenId}?mode=admin` + : `/screens/${screenId}`; + + await query( + `UPDATE menu_info + SET menu_url = $1, screen_code = $2 + WHERE objid = $3`, + [menuUrl, screen.screen_code, assignmentData.menuObjid] + ); + + logger.info("화면 할당 완료 (menu_info 업데이트)", { + screenId, + menuObjid: assignmentData.menuObjid, + menuUrl, + screenCode: screen.screen_code, + }); + } } /** @@ -1589,11 +1624,26 @@ export class ScreenManagementService { menuObjid: number, companyCode: string ): Promise { + // screen_menu_assignments에서 할당 삭제 await query( `DELETE FROM screen_menu_assignments WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`, [screenId, menuObjid, companyCode] ); + + // menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거) + await query( + `UPDATE menu_info + SET menu_url = NULL, screen_code = NULL + WHERE objid = $1`, + [menuObjid] + ); + + logger.info("화면 할당 해제 완료 (menu_info 업데이트)", { + screenId, + menuObjid, + companyCode, + }); } // ======================================== diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 1a162e78..bffb0d05 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -973,6 +973,96 @@ class TableCategoryValueService { return data; } } + + /** + * 2레벨 메뉴 목록 조회 + * + * 카테고리 컬럼 매핑 생성 시 메뉴 선택용 + * + * @param companyCode - 회사 코드 + * @returns 2레벨 메뉴 목록 + */ + async getSecondLevelMenus(companyCode: string): Promise { + const pool = getPool(); + + try { + logger.info("2레벨 메뉴 목록 조회", { companyCode }); + + // menu_info 테이블에 company_code 컬럼이 있는지 확인 + const columnCheckQuery = ` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'menu_info' AND column_name = 'company_code' + `; + const columnCheck = await pool.query(columnCheckQuery); + const hasCompanyCode = columnCheck.rows.length > 0; + + logger.info("menu_info 테이블 company_code 컬럼 존재 여부", { hasCompanyCode }); + + let query: string; + let params: any[]; + + if (!hasCompanyCode) { + // company_code 컬럼이 없는 경우: 모든 2레벨 사용자 메뉴 조회 + query = ` + SELECT + m1.objid as "menuObjid", + m1.menu_name_kor as "menuName", + m0.menu_name_kor as "parentMenuName", + m1.screen_code as "screenCode" + FROM menu_info m1 + INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid + WHERE m1.menu_type = 1 + AND m1.status = 'active' + AND m0.parent_obj_id = 0 + ORDER BY m0.seq, m1.seq + `; + params = []; + } else if (companyCode === "*") { + // 최고 관리자: 모든 회사의 2레벨 사용자 메뉴 조회 + query = ` + SELECT + m1.objid as "menuObjid", + m1.menu_name_kor as "menuName", + m0.menu_name_kor as "parentMenuName", + m1.screen_code as "screenCode" + FROM menu_info m1 + INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid + WHERE m1.menu_type = 1 + AND m1.status = 'active' + AND m0.parent_obj_id = 0 + ORDER BY m0.seq, m1.seq + `; + params = []; + } else { + // 일반 회사: 자신의 회사 메뉴만 조회 (공통 메뉴 제외) + query = ` + SELECT + m1.objid as "menuObjid", + m1.menu_name_kor as "menuName", + m0.menu_name_kor as "parentMenuName", + m1.screen_code as "screenCode" + FROM menu_info m1 + INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid + WHERE m1.menu_type = 1 + AND m1.status = 'active' + AND m0.parent_obj_id = 0 + AND m1.company_code = $1 + ORDER BY m0.seq, m1.seq + `; + params = [companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`2레벨 메뉴 ${result.rows.length}개 조회 완료`, { companyCode }); + + return result.rows; + } catch (error: any) { + logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`, { error }); + throw error; + } + } } export default new TableCategoryValueService(); diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index ac8b62fd..06bf6abd 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -249,21 +249,78 @@ export class TableManagementService { [tableName, size, offset] ); + // 🆕 category_column_mapping 조회 + const tableExistsResult = await query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'category_column_mapping' + ) as table_exists` + ); + const mappingTableExists = tableExistsResult[0]?.table_exists === true; + + let categoryMappings: Map = new Map(); + if (mappingTableExists && companyCode) { + logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", { tableName, companyCode }); + + const mappings = await query( + `SELECT + logical_column_name as "columnName", + menu_objid as "menuObjid" + FROM category_column_mapping + WHERE table_name = $1 + AND company_code = $2`, + [tableName, companyCode] + ); + + logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", { + tableName, + companyCode, + mappingCount: mappings.length, + mappings: mappings + }); + + mappings.forEach((m: any) => { + if (!categoryMappings.has(m.columnName)) { + categoryMappings.set(m.columnName, []); + } + categoryMappings.get(m.columnName)!.push(Number(m.menuObjid)); + }); + + logger.info("✅ getColumnList: categoryMappings Map 생성 완료", { + size: categoryMappings.size, + entries: Array.from(categoryMappings.entries()) + }); + } + // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 - const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({ - ...column, - maxLength: column.maxLength ? Number(column.maxLength) : null, - numericPrecision: column.numericPrecision - ? Number(column.numericPrecision) - : null, - numericScale: column.numericScale ? Number(column.numericScale) : null, - displayOrder: column.displayOrder ? Number(column.displayOrder) : null, - // 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 - webType: - column.webType === "text" - ? this.inferWebType(column.dataType) - : column.webType, - })); + const columns: ColumnTypeInfo[] = rawColumns.map((column) => { + const baseColumn = { + ...column, + maxLength: column.maxLength ? Number(column.maxLength) : null, + numericPrecision: column.numericPrecision + ? Number(column.numericPrecision) + : null, + numericScale: column.numericScale ? Number(column.numericScale) : null, + displayOrder: column.displayOrder ? Number(column.displayOrder) : null, + // 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 + webType: + column.webType === "text" + ? this.inferWebType(column.dataType) + : column.webType, + }; + + // 카테고리 타입인 경우 categoryMenus 추가 + if (column.inputType === "category" && categoryMappings.has(column.columnName)) { + const menus = categoryMappings.get(column.columnName); + logger.info(`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`, { menus }); + return { + ...baseColumn, + categoryMenus: menus, + }; + } + + return baseColumn; + }); const totalPages = Math.ceil(total / size); @@ -429,7 +486,7 @@ export class TableManagementService { // 캐시 무효화 - 해당 테이블의 컬럼 캐시 삭제 cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); - + logger.info(`컬럼 설정 업데이트 완료: ${tableName}.${columnName}`); } catch (error) { logger.error( @@ -484,7 +541,7 @@ export class TableManagementService { cache.deleteByPattern(`table_columns:${tableName}:`); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); - logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`); + logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`); } catch (error) { logger.error( `전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`, @@ -3152,19 +3209,83 @@ export class TableManagementService { [tableName, companyCode] ); - const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({ - tableName: tableName, - columnName: col.columnName, - displayName: col.displayName, - dataType: col.dataType || "varchar", - inputType: col.inputType, - detailSettings: col.detailSettings, - description: "", // 필수 필드 추가 - isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 - isPrimaryKey: false, - displayOrder: 0, - isVisible: true, - })); + // category_column_mapping 테이블 존재 여부 확인 + const tableExistsResult = await query( + `SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'category_column_mapping' + ) as table_exists` + ); + const mappingTableExists = tableExistsResult[0]?.table_exists === true; + + // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회 + let categoryMappings: Map = new Map(); + if (mappingTableExists) { + logger.info("카테고리 매핑 조회 시작", { tableName, companyCode }); + + const mappings = await query( + `SELECT + logical_column_name as "columnName", + menu_objid as "menuObjid" + FROM category_column_mapping + WHERE table_name = $1 + AND company_code = $2`, + [tableName, companyCode] + ); + + logger.info("카테고리 매핑 조회 완료", { + tableName, + companyCode, + mappingCount: mappings.length, + mappings: mappings + }); + + mappings.forEach((m: any) => { + if (!categoryMappings.has(m.columnName)) { + categoryMappings.set(m.columnName, []); + } + categoryMappings.get(m.columnName)!.push(Number(m.menuObjid)); + }); + + logger.info("categoryMappings Map 생성 완료", { + size: categoryMappings.size, + entries: Array.from(categoryMappings.entries()) + }); + } else { + logger.warn("category_column_mapping 테이블이 존재하지 않음"); + } + + const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { + const baseInfo = { + tableName: tableName, + columnName: col.columnName, + displayName: col.displayName, + dataType: col.dataType || "varchar", + inputType: col.inputType, + detailSettings: col.detailSettings, + description: "", // 필수 필드 추가 + isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 + isPrimaryKey: false, + displayOrder: 0, + isVisible: true, + }; + + // 카테고리 타입인 경우 categoryMenus 추가 + if (col.inputType === "category" && categoryMappings.has(col.columnName)) { + const menus = categoryMappings.get(col.columnName); + logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, { menus }); + return { + ...baseInfo, + categoryMenus: menus, + }; + } + + if (col.inputType === "category") { + logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`); + } + + return baseInfo; + }); logger.info( `컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼` diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 238ee616..2fb83df4 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -17,6 +17,7 @@ import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { ddlApi } from "@/lib/api/ddl"; +import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; @@ -54,6 +55,14 @@ interface ColumnTypeInfo { referenceTable?: string; referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 + categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 +} + +interface SecondLevelMenu { + menuObjid: number; + menuName: string; + parentMenuName: string; + screenCode?: string; } export default function TableManagementPage() { @@ -89,6 +98,9 @@ export default function TableManagementPage() { const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateSourceTable, setDuplicateSourceTable] = useState(null); + // 🆕 Category 타입용: 2레벨 메뉴 목록 + const [secondLevelMenus, setSecondLevelMenus] = useState([]); + // 로그 뷰어 상태 const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerTableName, setLogViewerTableName] = useState(""); @@ -224,6 +236,22 @@ export default function TableManagementPage() { } }; + // 🆕 2레벨 메뉴 목록 로드 + const loadSecondLevelMenus = async () => { + try { + const response = await getSecondLevelMenus(); + if (response.success && response.data) { + setSecondLevelMenus(response.data); + } else { + console.warn("⚠️ 2레벨 메뉴 로드 실패:", response); + setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제 + } + } catch (error) { + console.error("❌ 2레벨 메뉴 로드 에러:", error); + setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정 + } + }; + // 테이블 목록 로드 const loadTables = async () => { setLoading(true); @@ -257,10 +285,17 @@ export default function TableManagementPage() { if (response.data.success) { const data = response.data.data; + console.log("📥 원본 API 응답:", { + hasColumns: !!(data.columns || data), + firstColumn: (data.columns || data)[0], + statusColumn: (data.columns || data).find((col: any) => col.columnName === "status"), + }); + // 컬럼 데이터에 기본값 설정 const processedColumns = (data.columns || data).map((col: any) => ({ ...col, inputType: col.inputType || "text", // 기본값: text + categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 })); if (page === 1) { @@ -438,12 +473,72 @@ export default function TableManagementPage() { // console.log("저장할 컬럼 설정:", columnSetting); + console.log("💾 저장할 컬럼 정보:", { + columnName: column.columnName, + inputType: column.inputType, + categoryMenus: column.categoryMenus, + hasCategoryMenus: !!column.categoryMenus, + categoryMenusLength: column.categoryMenus?.length || 0, + }); + const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ columnSetting, ]); if (response.data.success) { - toast.success("컬럼 설정이 성공적으로 저장되었습니다."); + console.log("✅ 컬럼 설정 저장 성공"); + + // 🆕 Category 타입인 경우 컬럼 매핑 생성 + console.log("🔍 카테고리 조건 체크:", { + isCategory: column.inputType === "category", + hasCategoryMenus: !!column.categoryMenus, + length: column.categoryMenus?.length || 0, + }); + + if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) { + console.log("📥 카테고리 메뉴 매핑 시작:", { + columnName: column.columnName, + categoryMenus: column.categoryMenus, + count: column.categoryMenus.length, + }); + + let successCount = 0; + let failCount = 0; + + for (const menuObjid of column.categoryMenus) { + try { + const mappingResponse = await createColumnMapping({ + tableName: selectedTable, + logicalColumnName: column.columnName, + physicalColumnName: column.columnName, + menuObjid, + description: `${column.displayName} (메뉴별 카테고리)`, + }); + + if (mappingResponse.success) { + successCount++; + } else { + console.error("❌ 매핑 생성 실패:", mappingResponse); + failCount++; + } + } catch (error) { + console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); + failCount++; + } + } + + + if (successCount > 0 && failCount === 0) { + toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); + } else if (successCount > 0 && failCount > 0) { + toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); + } else if (failCount > 0) { + toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + } + } else { + toast.success("컬럼 설정이 성공적으로 저장되었습니다."); + } + // 원본 데이터 업데이트 setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); @@ -501,14 +596,78 @@ export default function TableManagementPage() { ); if (response.data.success) { + // 🆕 Category 타입 컬럼들의 메뉴 매핑 생성 + const categoryColumns = columns.filter( + (col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0 + ); + + console.log("📥 전체 저장: 카테고리 컬럼 확인", { + totalColumns: columns.length, + categoryColumns: categoryColumns.length, + categoryColumnsData: categoryColumns.map((col) => ({ + columnName: col.columnName, + categoryMenus: col.categoryMenus, + })), + }); + + if (categoryColumns.length > 0) { + let totalSuccessCount = 0; + let totalFailCount = 0; + + for (const column of categoryColumns) { + for (const menuObjid of column.categoryMenus!) { + try { + console.log("🔄 매핑 API 호출:", { + tableName: selectedTable, + columnName: column.columnName, + menuObjid, + }); + + const mappingResponse = await createColumnMapping({ + tableName: selectedTable, + logicalColumnName: column.columnName, + physicalColumnName: column.columnName, + menuObjid, + description: `${column.displayName} (메뉴별 카테고리)`, + }); + + console.log("✅ 매핑 API 응답:", mappingResponse); + + if (mappingResponse.success) { + totalSuccessCount++; + } else { + console.error("❌ 매핑 생성 실패:", mappingResponse); + totalFailCount++; + } + } catch (error) { + console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); + totalFailCount++; + } + } + } + + console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount }); + + if (totalSuccessCount > 0) { + toast.success( + `테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.` + ); + } else if (totalFailCount > 0) { + toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`); + } else { + toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`); + } + } else { + toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`); + } + // 저장 성공 후 원본 데이터 업데이트 setOriginalColumns([...columns]); - toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`); // 테이블 목록 새로고침 (라벨 변경 반영) loadTables(); - // 저장 후 데이터 확인을 위해 다시 로드 + // 저장 후 데이터 다시 로드 setTimeout(() => { loadColumnTypes(selectedTable, 1, pageSize); }, 1000); @@ -539,6 +698,7 @@ export default function TableManagementPage() { useEffect(() => { loadTables(); loadCommonCodeCategories(); + loadSecondLevelMenus(); }, []); // 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드 @@ -1023,10 +1183,61 @@ export default function TableManagementPage() { )} - {/* 입력 타입이 'category'인 경우 안내 메시지 */} + {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} {column.inputType === "category" && ( -
- 메뉴별 카테고리 값이 자동으로 표시됩니다 +
+ +
+ {secondLevelMenus.length === 0 ? ( +

+ 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다. +

+ ) : ( + secondLevelMenus.map((menu) => { + // menuObjid를 숫자로 변환하여 비교 + const menuObjidNum = Number(menu.menuObjid); + const isChecked = (column.categoryMenus || []).includes(menuObjidNum); + + return ( +
+ { + const currentMenus = column.categoryMenus || []; + const newMenus = e.target.checked + ? [...currentMenus, menuObjidNum] + : currentMenus.filter((id) => id !== menuObjidNum); + + setColumns((prev) => + prev.map((col) => + col.columnName === column.columnName + ? { ...col, categoryMenus: newMenus } + : col + ) + ); + }} + className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" + /> + +
+ ); + }) + )} +
+ {column.categoryMenus && column.categoryMenus.length > 0 && ( +

+ {column.categoryMenus.length}개 메뉴 선택됨 +

+ )}
)} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} diff --git a/frontend/components/table-category/AddCategoryColumnDialog.tsx b/frontend/components/table-category/AddCategoryColumnDialog.tsx index e1aae20a..38b8e975 100644 --- a/frontend/components/table-category/AddCategoryColumnDialog.tsx +++ b/frontend/components/table-category/AddCategoryColumnDialog.tsx @@ -21,15 +21,22 @@ import { SelectValue, } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; +import { Checkbox } from "@/components/ui/checkbox"; import { Plus } from "lucide-react"; import { toast } from "sonner"; import { createColumnMapping } from "@/lib/api/tableCategoryValue"; import { tableManagementApi } from "@/lib/api/tableManagement"; +import { apiClient } from "@/lib/api/client"; + +interface SecondLevelMenu { + menuObjid: number; + menuName: string; + parentMenuName: string; + screenCode?: string; +} interface AddCategoryColumnDialogProps { tableName: string; - menuObjid: number; - menuName: string; onSuccess: () => void; } @@ -37,27 +44,31 @@ interface AddCategoryColumnDialogProps { * 카테고리 컬럼 추가 다이얼로그 * * 논리적 컬럼명과 물리적 컬럼명을 매핑하여 메뉴별로 독립적인 카테고리 관리 가능 + * + * 2레벨 메뉴를 선택하면 해당 메뉴의 모든 하위 메뉴에서 사용 가능 */ export function AddCategoryColumnDialog({ tableName, - menuObjid, - menuName, onSuccess, }: AddCategoryColumnDialogProps) { const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); const [physicalColumns, setPhysicalColumns] = useState([]); + const [secondLevelMenus, setSecondLevelMenus] = useState([]); + const [selectedMenus, setSelectedMenus] = useState([]); const [logicalColumnName, setLogicalColumnName] = useState(""); const [physicalColumnName, setPhysicalColumnName] = useState(""); const [description, setDescription] = useState(""); - // 테이블의 실제 컬럼 목록 조회 + // 다이얼로그 열릴 때 데이터 로드 useEffect(() => { if (open) { loadPhysicalColumns(); + loadSecondLevelMenus(); } }, [open, tableName]); + // 테이블의 실제 컬럼 목록 조회 const loadPhysicalColumns = async () => { try { const response = await tableManagementApi.getTableColumns(tableName); @@ -70,6 +81,32 @@ export function AddCategoryColumnDialog({ } }; + // 2레벨 메뉴 목록 조회 + const loadSecondLevelMenus = async () => { + try { + const response = await apiClient.get<{ + success: boolean; + data: SecondLevelMenu[]; + }>("table-categories/second-level-menus"); + + if (response.data.success && response.data.data) { + setSecondLevelMenus(response.data.data); + } + } catch (error) { + console.error("2레벨 메뉴 목록 조회 실패:", error); + toast.error("메뉴 목록을 불러올 수 없습니다"); + } + }; + + // 메뉴 선택/해제 + const toggleMenu = (menuObjid: number) => { + setSelectedMenus((prev) => + prev.includes(menuObjid) + ? prev.filter((id) => id !== menuObjid) + : [...prev, menuObjid] + ); + }; + const handleSave = async () => { // 입력 검증 if (!logicalColumnName.trim()) { @@ -82,24 +119,42 @@ export function AddCategoryColumnDialog({ return; } + if (selectedMenus.length === 0) { + toast.error("최소 하나 이상의 메뉴를 선택해주세요"); + return; + } + setLoading(true); try { - const response = await createColumnMapping({ - tableName, - logicalColumnName: logicalColumnName.trim(), - physicalColumnName, - menuObjid, - description: description.trim() || undefined, - }); + // 선택된 각 메뉴에 대해 매핑 생성 + const promises = selectedMenus.map((menuObjid) => + createColumnMapping({ + tableName, + logicalColumnName: logicalColumnName.trim(), + physicalColumnName, + menuObjid, + description: description.trim() || undefined, + }) + ); - if (response.success) { - toast.success("논리적 컬럼이 추가되었습니다"); + const results = await Promise.all(promises); + + // 모든 요청이 성공했는지 확인 + const failedCount = results.filter((r) => !r.success).length; + + if (failedCount === 0) { + toast.success(`논리적 컬럼이 ${selectedMenus.length}개 메뉴에 추가되었습니다`); setOpen(false); resetForm(); onSuccess(); + } else if (failedCount < results.length) { + toast.warning( + `${results.length - failedCount}개 메뉴에 추가 성공, ${failedCount}개 실패` + ); + onSuccess(); } else { - toast.error(response.error || "컬럼 매핑 생성에 실패했습니다"); + toast.error("모든 메뉴에 대한 매핑 생성에 실패했습니다"); } } catch (error: any) { console.error("컬럼 매핑 생성 실패:", error); @@ -113,6 +168,7 @@ export function AddCategoryColumnDialog({ setLogicalColumnName(""); setPhysicalColumnName(""); setDescription(""); + setSelectedMenus([]); }; return ( @@ -130,21 +186,11 @@ export function AddCategoryColumnDialog({ 카테고리 컬럼 추가 - 같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다 + 2레벨 메뉴를 선택하면 해당 메뉴의 모든 하위 메뉴에서 사용할 수 있습니다
- {/* 적용 메뉴 (읽기 전용) */} -
- - -
- {/* 실제 컬럼 선택 */}
+ {/* 적용할 2레벨 메뉴 선택 (체크박스) */} +
+ +
+ {secondLevelMenus.length === 0 ? ( +

로딩 중...

+ ) : ( + secondLevelMenus.map((menu) => ( +
+ toggleMenu(menu.menuObjid)} + className="h-4 w-4" + /> + +
+ )) + )} +
+

+ 선택한 메뉴의 모든 하위 메뉴에서 이 카테고리를 사용할 수 있습니다 +

+ {selectedMenus.length > 0 && ( +

+ {selectedMenus.length}개 메뉴 선택됨 +

+ )} +
+ {/* 설명 (선택사항) */}
@@ -207,7 +290,7 @@ export function AddCategoryColumnDialog({