# 카테고리 메뉴 스코프 개선 계획서 ## 1. 문제 정의 ### 현재 문제점 **카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가** #### 구체적 상황 - `기준정보 > 품목정보` (item_info 테이블 사용) - `영업관리 > 판매품목정보` (item_info 테이블 사용) - 두 메뉴가 같은 테이블(`item_info`)을 사용하지만, 표시되어야 할 카테고리는 달라야 함 #### 현재 로직의 문제 ```typescript // 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회 const siblings = await getSiblingMenus(currentMenuId); const categories = await getCategoriesFromTables(siblings.tables); ``` **결과**: - `기준정보 > 품목정보`에서 보이지 않아야 할 카테고리가 표시됨 - `영업관리 > 판매품목정보`에서만 보여야 할 카테고리가 `기준정보`에도 표시됨 --- ## 2. 해결 방안 ### 핵심 아이디어 **카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링** ### 2.1 데이터베이스 구조 (이미 준비됨) ```sql -- table_column_category_values 테이블에 이미 menu_objid 컬럼 존재 SELECT * FROM table_column_category_values; -- 컬럼 구조 -- value_id: PK -- table_name: 테이블명 -- column_name: 컬럼명 -- value_code: 카테고리 코드 -- value_label: 카테고리 라벨 -- menu_objid: 2레벨 메뉴 OBJID (핵심!) -- company_code: 회사 코드 -- ... ``` ### 2.2 메뉴 계층 구조 ``` 1레벨 메뉴 (기준정보, objid=1) ├── 2레벨 메뉴 (회사정보, objid=101) ├── 2레벨 메뉴 (부서관리, objid=102) └── 2레벨 메뉴 (품목정보, objid=103) ← 여기에 item_info 테이블 사용 1레벨 메뉴 (영업관리, objid=2) ├── 2레벨 메뉴 (견적관리, objid=201) ├── 2레벨 메뉴 (수주관리, objid=202) └── 2레벨 메뉴 (판매품목정보, objid=203) ← 여기도 item_info 테이블 사용 ``` ### 2.3 카테고리 값 저장 방식 ```sql -- 기준정보 > 품목정보에서 사용할 카테고리 INSERT INTO table_column_category_values (table_name, column_name, value_code, value_label, menu_objid, company_code) VALUES ('item_info', 'category_type', 'STOCK_ITEM', '재고품목', 103, 'COMPANY_A'), ('item_info', 'category_type', 'ASSET', '자산', 103, 'COMPANY_A'); -- 영업관리 > 판매품목정보에서 사용할 카테고리 INSERT INTO table_column_category_values (table_name, column_name, value_code, value_label, menu_objid, company_code) VALUES ('item_info', 'category_type', 'SALES_ITEM', '판매품목', 203, 'COMPANY_A'), ('item_info', 'category_type', 'SERVICE', '서비스', 203, 'COMPANY_A'); ``` --- ## 3. 구현 단계 ### Phase 1: 백엔드 API 수정 #### 3.1 카테고리 조회 API 개선 **파일**: `backend-node/src/controllers/categoryController.ts` **현재 로직**: ```typescript export async function getCategoryColumns(req: Request, res: Response) { const { tableName } = req.params; const companyCode = req.user!.companyCode; // 형제 메뉴들의 테이블에서 카테고리 컬럼 조회 const siblings = await getSiblingMenuTables(menuObjid); const query = ` SELECT DISTINCT table_name, column_name, value_code, value_label FROM table_column_category_values WHERE table_name IN (${siblings.join(',')}) AND company_code = $1 `; } ``` **개선된 로직**: ```typescript export async function getCategoryColumns(req: Request, res: Response) { const { tableName, menuObjid } = req.params; // menuObjid 추가 const companyCode = req.user!.companyCode; // 2레벨 메뉴 OBJID 찾기 const topLevelMenuId = await getTopLevelMenuId(menuObjid); const query = ` SELECT table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon FROM table_column_category_values WHERE table_name = $1 AND menu_objid = $2 AND company_code = $3 AND is_active = true ORDER BY value_order, value_label `; const result = await pool.query(query, [tableName, topLevelMenuId, companyCode]); logger.info("카테고리 컬럼 조회 (메뉴 스코프)", { tableName, menuObjid: topLevelMenuId, companyCode, categoryCount: result.rowCount, }); return res.json({ success: true, data: result.rows, }); } ``` #### 3.2 2레벨 메뉴 OBJID 찾기 함수 ```typescript /** * 현재 메뉴의 최상위(2레벨) 메뉴 OBJID 찾기 * * @param menuObjid - 현재 메뉴 OBJID * @returns 2레벨 메뉴 OBJID */ async function getTopLevelMenuId(menuObjid: number): Promise { const query = ` WITH RECURSIVE menu_hierarchy AS ( -- 현재 메뉴 SELECT objid, parent_obj_id, menu_type, 1 as level FROM menu_info WHERE objid = $1 UNION ALL -- 부모 메뉴들 재귀 조회 SELECT m.objid, m.parent_obj_id, m.menu_type, mh.level + 1 FROM menu_info m JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id ) SELECT objid FROM menu_hierarchy WHERE parent_obj_id IS NULL OR menu_type = 1 -- 1레벨 메뉴의 자식 = 2레벨 ORDER BY level DESC LIMIT 1; `; const result = await pool.query(query, [menuObjid]); if (result.rowCount === 0) { throw new Error(`메뉴를 찾을 수 없습니다: ${menuObjid}`); } return result.rows[0].objid; } ``` #### 3.3 카테고리 값 생성 API 개선 **파일**: `backend-node/src/controllers/categoryController.ts` ```typescript export async function createCategoryValue(req: Request, res: Response) { const companyCode = req.user!.companyCode; const { tableName, columnName, valueCode, valueLabel, menuObjid, // 필수로 추가 parentValueId, depth, description, color, icon, } = req.body; // 입력 검증 if (!tableName || !columnName || !valueCode || !valueLabel || !menuObjid) { return res.status(400).json({ success: false, message: "필수 필드가 누락되었습니다", }); } const query = ` INSERT INTO table_column_category_values ( table_name, column_name, value_code, value_label, menu_objid, parent_value_id, depth, description, color, icon, company_code, created_by, updated_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) RETURNING * `; const result = await pool.query(query, [ tableName, columnName, valueCode, valueLabel, menuObjid, parentValueId || null, depth || 1, description || null, color || null, icon || null, companyCode, req.user!.userId, req.user!.userId, ]); logger.info("카테고리 값 생성", { tableName, columnName, valueCode, menuObjid, companyCode, }); return res.json({ success: true, data: result.rows[0], }); } ``` ### Phase 2: 프론트엔드 - 테이블 타입 관리 UI 개선 #### 2.1 테이블 타입 관리 컴포넌트 수정 **파일**: `frontend/components/admin/table-type-management/TableTypeManagement.tsx` **추가할 기능**: 1. 카테고리 컬럼 설정 시 "적용할 메뉴 선택" 기능 2. 2레벨 메뉴 목록 조회 및 다중 선택 UI 3. 선택된 메뉴별로 카테고리 값 생성 ```typescript interface CategoryMenuScope { menuObjid: number; menuName: string; parentMenuName: string; isSelected: boolean; } function CategoryColumnConfig({ tableName, columnName }: Props) { const [menuScopes, setMenuScopes] = useState([]); const [selectedMenus, setSelectedMenus] = useState([]); // 2레벨 메뉴 목록 조회 useEffect(() => { async function loadMenuScopes() { const response = await apiClient.get("/api/menus/second-level"); if (response.data.success) { setMenuScopes(response.data.data); } } loadMenuScopes(); }, []); // 카테고리 값 저장 시 선택된 메뉴들에 대해 각각 저장 const handleSaveCategoryValue = async (categoryData: any) => { for (const menuObjid of selectedMenus) { await apiClient.post("/api/categories/values", { ...categoryData, tableName, columnName, menuObjid, // 메뉴별로 저장 }); } toast.success("카테고리가 선택된 메뉴에 저장되었습니다"); }; return (
{menuScopes.map((scope) => (
{ if (checked) { setSelectedMenus([...selectedMenus, scope.menuObjid]); } else { setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid)); } }} /> {scope.parentMenuName} → {scope.menuName}
))}
{selectedMenus.length === 0 && (

최소 하나 이상의 메뉴를 선택해주세요

)} {/* 카테고리 값 추가 UI */}
); } ``` #### 2.2 2레벨 메뉴 조회 API 추가 **파일**: `backend-node/src/controllers/menuController.ts` ```typescript /** * 2레벨 메뉴 목록 조회 * (카테고리 스코프 선택용) */ export async function getSecondLevelMenus(req: Request, res: Response) { const companyCode = req.user!.companyCode; const query = ` SELECT m2.objid as menu_objid, m2.menu_name_kor as menu_name, m1.menu_name_kor as parent_menu_name, m2.screen_code FROM menu_info m2 JOIN menu_info m1 ON m2.parent_obj_id = m1.objid WHERE m2.menu_type = 2 -- 2레벨 메뉴 AND (m2.company_code = $1 OR m2.company_code = '*') AND m2.status = 'Y' ORDER BY m1.seq, m2.seq `; const result = await pool.query(query, [companyCode]); return res.json({ success: true, data: result.rows, }); } ``` **라우트 등록**: ```typescript router.get("/api/menus/second-level", authenticate, getSecondLevelMenus); ``` ### Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선 #### 3.1 카테고리 컴포넌트 조회 로직 변경 **파일**: `frontend/lib/registry/components/category/CategoryComponent.tsx` **현재 로직** (형제 메뉴 기반): ```typescript useEffect(() => { async function loadCategories() { // 형제 메뉴들의 테이블에서 카테고리 조회 const response = await apiClient.get(`/api/categories/${tableName}`); } loadCategories(); }, [tableName]); ``` **개선된 로직** (2레벨 메뉴 기반): ```typescript useEffect(() => { async function loadCategories() { // 현재 메뉴 OBJID 가져오기 const menuObjid = screenConfig.menuObjid; // 또는 URL에서 파싱 if (!menuObjid) { console.warn("메뉴 OBJID를 찾을 수 없습니다"); return; } // 2레벨 메뉴 기준으로 카테고리 조회 const response = await apiClient.get( `/api/categories/${tableName}/menu/${menuObjid}` ); if (response.data.success) { setCategories(response.data.data); } } loadCategories(); }, [tableName, screenConfig.menuObjid]); ``` #### 3.2 API 클라이언트 함수 추가 **파일**: `frontend/lib/api/category.ts` ```typescript /** * 특정 메뉴 스코프의 카테고리 컬럼 조회 * * @param tableName - 테이블명 * @param menuObjid - 메뉴 OBJID */ export async function getCategoriesByMenuScope( tableName: string, menuObjid: number ): Promise> { try { const response = await apiClient.get( `/api/categories/${tableName}/menu/${menuObjid}` ); return response.data; } catch (error: any) { return { success: false, error: error.message, }; } } ``` --- ## 4. 데이터 마이그레이션 ### 4.1 기존 카테고리 값에 menu_objid 설정 **문제**: 기존 카테고리 값들은 `menu_objid`가 설정되지 않음 **해결**: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도 #### 마이그레이션 스크립트 (선택사항) ```sql -- db/migrations/053_backfill_category_menu_objid.sql -- Step 1: 기존 카테고리 값 확인 SELECT value_id, table_name, column_name, value_label, menu_objid, company_code FROM table_column_category_values WHERE menu_objid IS NULL OR menu_objid = 0; -- Step 2: 기본값 설정 (예시) -- 관리자가 수동으로 올바른 menu_objid를 설정해야 함 UPDATE table_column_category_values SET menu_objid = 103 -- 예: 기준정보 > 품목정보 WHERE table_name = 'item_info' AND column_name = 'category_type' AND value_code IN ('STOCK_ITEM', 'ASSET') AND (menu_objid IS NULL OR menu_objid = 0); UPDATE table_column_category_values SET menu_objid = 203 -- 예: 영업관리 > 판매품목정보 WHERE table_name = 'item_info' AND column_name = 'category_type' AND value_code IN ('SALES_ITEM', 'SERVICE') AND (menu_objid IS NULL OR menu_objid = 0); ``` ### 4.2 UI에서 menu_objid 미설정 경고 ```typescript // TableTypeManagement.tsx function CategoryColumnList({ categoryColumns }: Props) { const unassignedCategories = categoryColumns.filter( (col) => !col.menuObjid || col.menuObjid === 0 ); if (unassignedCategories.length > 0) { return ( 메뉴 스코프 미설정 {unassignedCategories.length}개의 카테고리 값에 메뉴가 설정되지 않았습니다.
각 카테고리가 어떤 메뉴에서 사용될지 설정해주세요.
); } return
...
; } ``` --- ## 5. 테스트 시나리오 ### 5.1 기본 기능 테스트 1. **2레벨 메뉴 조회** - `GET /api/menus/second-level` - 기준정보, 영업관리 등 2레벨 메뉴들이 조회되는지 확인 2. **카테고리 값 생성 (메뉴 스코프 포함)** - 테이블 타입 관리에서 카테고리 컬럼 선택 - 적용할 메뉴 선택 (예: 영업관리 > 판매품목정보) - 카테고리 값 추가 - DB에 올바른 `menu_objid`로 저장되는지 확인 3. **카테고리 컴포넌트 조회 (메뉴별 필터링)** - `기준정보 > 품목정보` 화면 접속 - 해당 메뉴의 카테고리만 표시되는지 확인 - `영업관리 > 판매품목정보` 화면 접속 - 다른 카테고리가 표시되는지 확인 ### 5.2 엣지 케이스 테스트 1. **menu_objid 미설정 카테고리** - 기존 카테고리 값 (menu_objid = NULL) - 경고 메시지 표시 - 조회 시 제외되는지 확인 2. **여러 메뉴에 동일 카테고리** - 하나의 카테고리를 여러 메뉴에 적용 - 각 메뉴에서 독립적으로 관리되는지 확인 3. **최고 관리자 권한** - 최고 관리자는 모든 메뉴의 카테고리를 볼 수 있는지 확인 --- ## 6. 롤백 계획 만약 문제가 발생하면 다음 단계로 롤백: ### 6.1 백엔드 API 롤백 ```bash git revert ``` ### 6.2 데이터베이스 롤백 ```sql -- menu_objid 조건 제거 (임시) -- 기존 로직으로 돌아감 ``` ### 6.3 프론트엔드 롤백 - 카테고리 조회 시 `menuObjid` 파라미터 제거 - 형제 메뉴 기반 로직으로 복원 --- ## 7. 구현 우선순위 ### Phase 1 (필수) 1. ✅ 백엔드: `getTopLevelMenuId()` 함수 구현 2. ✅ 백엔드: 카테고리 조회 API에 `menu_objid` 필터링 추가 3. ✅ 백엔드: 카테고리 값 생성 시 `menu_objid` 필수 처리 4. ✅ 백엔드: 2레벨 메뉴 목록 조회 API 추가 ### Phase 2 (필수) 5. ✅ 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가 6. ✅ 프론트엔드: 카테고리 컴포넌트 조회 로직 변경 ### Phase 3 (권장) 7. ⏳ 데이터 마이그레이션: 기존 카테고리 값에 `menu_objid` 설정 8. ⏳ UI: `menu_objid` 미설정 경고 표시 9. ⏳ 테스트: 시나리오별 검증 ### Phase 4 (선택) 10. ⏳ 관리자 대시보드: 카테고리 사용 현황 통계 11. ⏳ 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경 --- ## 8. 기대 효과 ### 8.1 문제 해결 - ✅ 같은 테이블을 사용하는 메뉴들도 서로 다른 카테고리 사용 가능 - ✅ 메뉴별 카테고리 독립성 보장 - ✅ 관리자가 메뉴별로 카테고리를 명확히 제어 ### 8.2 사용자 경험 개선 - ✅ 카테고리가 메뉴 문맥에 맞게 표시됨 - ✅ 불필요한 카테고리가 표시되지 않음 - ✅ 직관적인 카테고리 관리 UI ### 8.3 확장성 - ✅ 향후 메뉴별 세밀한 권한 제어 가능 - ✅ 메뉴별 카테고리 통계 및 분석 가능 - ✅ 다른 컴포넌트에도 유사한 메뉴 스코프 패턴 적용 가능 --- ## 9. 참고 자료 - 데이터베이스 스키마: `table_column_category_values` 테이블 - 메뉴 구조: `menu_info` 테이블 - 카테고리 컴포넌트: `frontend/lib/registry/components/category/CategoryComponent.tsx` - 테이블 타입 관리: `frontend/components/admin/table-type-management/TableTypeManagement.tsx` - API 클라이언트: `frontend/lib/api/category.ts` --- ## 10. 결론 사용자가 제안한 **"테이블 타입 관리에서 카테고리 컬럼 설정 시 2레벨 메뉴 선택"** 방식이 가장 현실적이고 효과적인 해결책입니다. **핵심 변경사항**: 1. 카테고리 값 저장 시 `menu_objid` 필수 입력 2. 카테고리 조회 시 2레벨 메뉴 기준 필터링 (형제 메뉴 기준 제거) 3. 테이블 타입 관리 UI에서 메뉴 선택 기능 추가 이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.