# 카테고리 컴포넌트 메뉴 기반 전환 계획서 ## 📋 현재 문제점 ### 테이블 기반 스코프의 한계 **현재 상황**: - 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴 - `table_column_category_values` 테이블에서 `table_name + column_name`으로 카테고리 조회 **문제 발생**: ``` 영업관리 (menu_id: 200) ├── 고객관리 (menu_id: 201) - 테이블: customer_info ├── 계약관리 (menu_id: 202) - 테이블: contract_info ├── 주문관리 (menu_id: 203) - 테이블: order_info └── 영업관리 공통코드 (menu_id: 204) - 어떤 테이블 선택? ``` **문제**: - 영업관리 전체에서 사용할 공통 코드/카테고리를 관리하고 싶은데 - 각 하위 메뉴가 서로 다른 테이블을 사용하므로 - 특정 테이블 하나를 선택하면 다른 메뉴에서 사용할 수 없음 ### 예시: 영업관리 공통 코드 관리 불가 **원하는 동작**: - "영업관리 > 공통코드 관리" 메뉴에서 카테고리 생성 - 이 카테고리는 영업관리의 **모든 하위 메뉴**에서 사용 가능 - 고객관리, 계약관리, 주문관리 화면 모두에서 같은 카테고리 공유 **현재 동작**: - 테이블별로 카테고리가 격리됨 - `customer_info` 테이블의 카테고리는 `contract_info`에서 사용 불가 - 각 테이블마다 동일한 카테고리를 중복 생성해야 함 (비효율) --- ## ✅ 해결 방안: 메뉴 기반 스코프 ### 핵심 개념 **메뉴 계층 구조를 카테고리 스코프로 사용**: - 카테고리를 생성할 때 `menu_id`를 기록 - 같은 부모 메뉴를 가진 **형제 메뉴들**이 카테고리를 공유 - 테이블과 무관하게 메뉴 구조에 따라 스코프 결정 ### 메뉴 스코프 규칙 ``` 영업관리 (parent_id: 0, menu_id: 200) ├── 고객관리 (parent_id: 200, menu_id: 201) ├── 계약관리 (parent_id: 200, menu_id: 202) ├── 주문관리 (parent_id: 200, menu_id: 203) └── 공통코드 관리 (parent_id: 200, menu_id: 204) ← 여기서 카테고리 생성 ``` **스코프 규칙**: - 204번 메뉴에서 카테고리 생성 → `menu_id = 204`로 저장 - 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능** - 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가 --- ## 📐 데이터베이스 설계 ### 기존 테이블 수정 ```sql -- table_column_category_values 테이블에 menu_id 추가 ALTER TABLE table_column_category_values ADD COLUMN menu_id INTEGER; -- 외래키 추가 ALTER TABLE table_column_category_values ADD CONSTRAINT fk_category_value_menu FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id); -- UNIQUE 제약조건 수정 (menu_id 추가) ALTER TABLE table_column_category_values DROP CONSTRAINT IF EXISTS unique_category_value; ALTER TABLE table_column_category_values ADD CONSTRAINT unique_category_value UNIQUE (table_name, column_name, value_code, menu_id, company_code); -- 인덱스 추가 CREATE INDEX idx_category_value_menu ON table_column_category_values(menu_id, table_name, column_name, company_code); ``` ### 필드 설명 | 필드 | 설명 | 예시 | | -------------- | ------------------------ | --------------------- | | `table_name` | 어떤 테이블의 컬럼인지 | `customer_info` | | `column_name` | 어떤 컬럼의 값인지 | `customer_type` | | `menu_id` | 어느 메뉴에서 생성했는지 | `204` (공통코드 관리) | | `company_code` | 멀티테넌시 | `COMPANY_A` | --- ## 🔧 백엔드 구현 ### 1. 메뉴 스코프 로직 추가 #### 형제 메뉴 조회 함수 ```typescript // backend-node/src/services/menuService.ts /** * 메뉴의 형제 메뉴 ID 목록 조회 * (같은 부모를 가진 메뉴들) */ export async function getSiblingMenuIds(menuId: number): Promise { const pool = getPool(); // 1. 현재 메뉴의 부모 찾기 const parentQuery = ` SELECT parent_id FROM menu_info WHERE menu_id = $1 `; const parentResult = await pool.query(parentQuery, [menuId]); if (parentResult.rows.length === 0) { return [menuId]; // 메뉴가 없으면 자기 자신만 } const parentId = parentResult.rows[0].parent_id; if (!parentId || parentId === 0) { // 최상위 메뉴인 경우 자기 자신만 return [menuId]; } // 2. 같은 부모를 가진 형제 메뉴들 조회 const siblingsQuery = ` SELECT menu_id FROM menu_info WHERE parent_id = $1 `; const siblingsResult = await pool.query(siblingsQuery, [parentId]); return siblingsResult.rows.map((row) => row.menu_id); } ``` ### 2. 카테고리 값 조회 API 수정 #### 서비스 로직 수정 ```typescript // backend-node/src/services/tableCategoryValueService.ts /** * 카테고리 값 목록 조회 (메뉴 스코프 적용) */ async getCategoryValues( tableName: string, columnName: string, menuId: number, // ← 추가 companyCode: string, includeInactive: boolean = false ): Promise { logger.info("카테고리 값 조회 (메뉴 스코프)", { tableName, columnName, menuId, companyCode, }); const pool = getPool(); // 1. 형제 메뉴 ID 조회 const siblingMenuIds = await getSiblingMenuIds(menuId); logger.info("형제 메뉴 ID 목록", { menuId, siblingMenuIds }); // 2. 카테고리 값 조회 let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 회사 데이터 조회 query = ` SELECT value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", menu_id AS "menuId", created_at AS "createdAt", created_by AS "createdBy" FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND menu_id = ANY($3) -- ← 형제 메뉴 포함 ${!includeInactive ? 'AND is_active = true' : ''} ORDER BY value_order, value_label `; params = [tableName, columnName, siblingMenuIds]; } else { // 일반 회사: 자신의 데이터만 조회 query = ` SELECT value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", menu_id AS "menuId", created_at AS "createdAt", created_by AS "createdBy" FROM table_column_category_values WHERE table_name = $1 AND column_name = $2 AND menu_id = ANY($3) -- ← 형제 메뉴 포함 AND company_code = $4 -- ← 회사별 필터링 ${!includeInactive ? 'AND is_active = true' : ''} ORDER BY value_order, value_label `; params = [tableName, columnName, siblingMenuIds, companyCode]; } const result = await pool.query(query, params); logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`); return result.rows; } ``` ### 3. 카테고리 값 추가 API 수정 ```typescript /** * 카테고리 값 추가 (menu_id 저장) */ async addCategoryValue( value: TableCategoryValue, menuId: number, // ← 추가 companyCode: string, userId: string ): Promise { logger.info("카테고리 값 추가 (메뉴 스코프)", { tableName: value.tableName, columnName: value.columnName, valueCode: value.valueCode, menuId, companyCode, }); const pool = getPool(); const query = ` INSERT INTO table_column_category_values ( table_name, column_name, value_code, value_label, value_order, parent_value_id, depth, description, color, icon, is_active, is_default, company_code, menu_id, -- ← menu_id 추가 created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING value_id AS "valueId", table_name AS "tableName", column_name AS "columnName", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, color, icon, is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", menu_id AS "menuId", created_at AS "createdAt", created_by AS "createdBy" `; const result = await pool.query(query, [ value.tableName, value.columnName, value.valueCode, value.valueLabel, value.valueOrder || 0, value.parentValueId || null, value.depth || 1, value.description || null, value.color || null, value.icon || null, value.isActive !== false, value.isDefault || false, companyCode, menuId, // ← 카테고리 관리 화면의 menu_id userId, ]); logger.info("카테고리 값 추가 성공", { valueId: result.rows[0].valueId, menuId, }); return result.rows[0]; } ``` ### 4. 컨트롤러 수정 ```typescript // backend-node/src/controllers/tableCategoryValueController.ts /** * 카테고리 값 목록 조회 */ export async function getCategoryValues( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName, columnName } = req.params; const { menuId, includeInactive } = req.query; // ← menuId 추가 const companyCode = req.user!.companyCode; if (!menuId) { res.status(400).json({ success: false, message: "menuId는 필수입니다", }); return; } const service = new TableCategoryValueService(); const values = await service.getCategoryValues( tableName, columnName, Number(menuId), // ← menuId 전달 companyCode, includeInactive === "true" ); res.json({ success: true, data: values, }); } catch (error: any) { logger.error("카테고리 값 조회 실패:", error); res.status(500).json({ success: false, message: "카테고리 값 조회 중 오류 발생", error: error.message, }); } } /** * 카테고리 값 추가 */ export async function addCategoryValue( req: AuthenticatedRequest, res: Response ): Promise { try { const { menuId, ...value } = req.body; // ← menuId 추가 const companyCode = req.user!.companyCode; const userId = req.user!.userId; if (!menuId) { res.status(400).json({ success: false, message: "menuId는 필수입니다", }); return; } const service = new TableCategoryValueService(); const newValue = await service.addCategoryValue( value, menuId, // ← menuId 전달 companyCode, userId ); res.json({ success: true, data: newValue, }); } catch (error: any) { logger.error("카테고리 값 추가 실패:", error); res.status(500).json({ success: false, message: "카테고리 값 추가 중 오류 발생", error: error.message, }); } } ``` --- ## 🎨 프론트엔드 구현 ### 1. API 클라이언트 수정 ```typescript // frontend/lib/api/tableCategoryValue.ts /** * 카테고리 값 목록 조회 (메뉴 스코프) */ export async function getCategoryValues( tableName: string, columnName: string, menuId: number, // ← 추가 includeInactive: boolean = false ) { try { const response = await apiClient.get<{ success: boolean; data: TableCategoryValue[]; }>(`/table-categories/${tableName}/${columnName}/values`, { params: { menuId, // ← menuId 쿼리 파라미터 추가 includeInactive, }, }); return response.data; } catch (error: any) { console.error("카테고리 값 조회 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 값 추가 */ export async function addCategoryValue( value: TableCategoryValue, menuId: number // ← 추가 ) { try { const response = await apiClient.post<{ success: boolean; data: TableCategoryValue; }>("/table-categories/values", { ...value, menuId, // ← menuId 포함 }); return response.data; } catch (error: any) { console.error("카테고리 값 추가 실패:", error); return { success: false, error: error.message }; } } ``` ### 2. CategoryColumnList 컴포넌트 수정 ```typescript // frontend/components/table-category/CategoryColumnList.tsx interface CategoryColumnListProps { tableName: string; menuId: number; // ← 추가 selectedColumn: string | null; onColumnSelect: (columnName: string, columnLabel: string) => void; } export function CategoryColumnList({ tableName, menuId, // ← 추가 selectedColumn, onColumnSelect, }: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { loadCategoryColumns(); }, [tableName, menuId]); // ← menuId 의존성 추가 const loadCategoryColumns = async () => { setIsLoading(true); try { // table_type_columns에서 input_type='category'인 컬럼 조회 const response = await apiClient.get( `/table-management/tables/${tableName}/columns` ); const allColumns = Array.isArray(response.data) ? response.data : response.data.data?.columns || []; // category 타입만 필터링 const categoryColumns = allColumns.filter( (col: any) => col.inputType === "category" || col.input_type === "category" ); const columnsWithCount = await Promise.all( categoryColumns.map(async (col: any) => { const colName = col.columnName || col.column_name; const colLabel = col.columnLabel || col.column_label || colName; // 각 컬럼의 값 개수 가져오기 (menuId 전달) let valueCount = 0; try { const valuesResult = await getCategoryValues( tableName, colName, menuId, // ← menuId 전달 false ); if (valuesResult.success && valuesResult.data) { valueCount = valuesResult.data.length; } } catch (error) { console.error(`항목 개수 조회 실패 (${colName}):`, error); } return { columnName: colName, columnLabel: colLabel, inputType: col.inputType || col.input_type, valueCount, }; }) ); setColumns(columnsWithCount); // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(firstCol.columnName, firstCol.columnLabel); } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); setColumns([]); } finally { setIsLoading(false); } }; // ... 나머지 렌더링 로직 } ``` ### 3. CategoryValueManager 컴포넌트 수정 ```typescript // frontend/components/table-category/CategoryValueManager.tsx interface CategoryValueManagerProps { tableName: string; columnName: string; menuId: number; // ← 추가 columnLabel?: string; onValueCountChange?: (count: number) => void; } export function CategoryValueManager({ tableName, columnName, menuId, // ← 추가 columnLabel, onValueCountChange, }: CategoryValueManagerProps) { const [values, setValues] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect(() => { loadCategoryValues(); }, [tableName, columnName, menuId]); // ← menuId 의존성 추가 const loadCategoryValues = async () => { setIsLoading(true); try { const response = await getCategoryValues( tableName, columnName, menuId, // ← menuId 전달 false ); if (response.success && response.data) { setValues(response.data); onValueCountChange?.(response.data.length); } } catch (error) { console.error("카테고리 값 조회 실패:", error); } finally { setIsLoading(false); } }; const handleAddValue = async (newValue: TableCategoryValue) => { try { const response = await addCategoryValue( { ...newValue, tableName, columnName, }, menuId // ← menuId 전달 ); if (response.success) { loadCategoryValues(); toast.success("카테고리 값이 추가되었습니다"); } } catch (error) { console.error("카테고리 값 추가 실패:", error); toast.error("카테고리 값 추가 중 오류가 발생했습니다"); } }; // ... 나머지 CRUD 로직 (menuId를 항상 포함) } ``` ### 4. 화면관리 시스템에서 menuId 전달 #### 화면 디자이너에서 menuId 추출 ```typescript // frontend/components/screen/ScreenDesigner.tsx export function ScreenDesigner() { const [selectedScreen, setSelectedScreen] = useState(null); // 선택된 화면의 menuId 추출 const currentMenuId = selectedScreen?.menuId; // CategoryWidget 렌더링 시 menuId 전달 return (
{/* ... */}
); } ``` #### CategoryWidget 컴포넌트 (신규 또는 수정) ```typescript // frontend/components/screen/widgets/CategoryWidget.tsx interface CategoryWidgetProps { tableName: string; menuId: number; // ← 추가 } export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) { const [selectedColumn, setSelectedColumn] = useState(null); const [selectedColumnLabel, setSelectedColumnLabel] = useState(""); const handleColumnSelect = (columnName: string, columnLabel: string) => { setSelectedColumn(columnName); setSelectedColumnLabel(columnLabel); }; return (
{/* 좌측: 카테고리 컬럼 리스트 */}
{/* 우측: 카테고리 값 관리 */}
{selectedColumn ? ( ) : (

좌측에서 카테고리 컬럼을 선택하세요

)}
); } ``` --- ## 🔄 기존 데이터 마이그레이션 ### 마이그레이션 스크립트 ```sql -- db/migrations/047_add_menu_id_to_category_values.sql -- 1. menu_id 컬럼 추가 (NULL 허용) ALTER TABLE table_column_category_values ADD COLUMN IF NOT EXISTS menu_id INTEGER; -- 2. 기존 데이터에 임시 menu_id 설정 -- (관리자가 수동으로 올바른 menu_id로 변경해야 함) UPDATE table_column_category_values SET menu_id = 1 WHERE menu_id IS NULL; -- 3. menu_id를 NOT NULL로 변경 ALTER TABLE table_column_category_values ALTER COLUMN menu_id SET NOT NULL; -- 4. 외래키 추가 ALTER TABLE table_column_category_values ADD CONSTRAINT fk_category_value_menu FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id); -- 5. UNIQUE 제약조건 재생성 ALTER TABLE table_column_category_values DROP CONSTRAINT IF EXISTS unique_category_value; ALTER TABLE table_column_category_values ADD CONSTRAINT unique_category_value UNIQUE (table_name, column_name, value_code, menu_id, company_code); -- 6. 인덱스 추가 CREATE INDEX idx_category_value_menu ON table_column_category_values(menu_id, table_name, column_name, company_code); COMMENT ON COLUMN table_column_category_values.menu_id IS '카테고리를 생성한 메뉴 ID (형제 메뉴에서 공유)'; ``` --- ## 📊 사용 시나리오 ### 시나리오: 영업관리 공통코드 관리 #### 1단계: 메뉴 구조 ``` 영업관리 (parent_id: 0, menu_id: 200) ├── 고객관리 (parent_id: 200, menu_id: 201) - customer_info 테이블 ├── 계약관리 (parent_id: 200, menu_id: 202) - contract_info 테이블 ├── 주문관리 (parent_id: 200, menu_id: 203) - order_info 테이블 └── 공통코드 관리 (parent_id: 200, menu_id: 204) - 카테고리 관리 전용 ``` #### 2단계: 카테고리 관리 화면 생성 1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_id: 204) 2. **화면 생성**: 화면관리 시스템에서 화면 생성 3. **테이블 선택**: 영업관리에서 사용할 **아무 테이블** (예: `customer_info`) - 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐 - 실제 스코프는 `menu_id`로 결정됨 4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭 #### 3단계: 카테고리 값 등록 1. **좌측 패널**: `customer_info` 테이블의 카테고리 컬럼 표시 - `customer_type` (고객 유형) - `customer_grade` (고객 등급) 2. **컬럼 선택**: `customer_type` 클릭 3. **우측 패널**: 카테고리 값 관리 - 추가 버튼 클릭 - 코드: `REGULAR`, 라벨: `일반 고객` - 색상: `#3b82f6` - **저장 시 `menu_id = 204`로 자동 저장됨** #### 4단계: 다른 화면에서 사용 ##### ✅ 형제 메뉴에서 사용 가능 **고객관리 화면** (menu_id: 201): - `customer_type` 컬럼을 category-select 위젯으로 배치 - 드롭다운에 `일반 고객`, `VIP 고객` 등 표시됨 ✅ - **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴 **계약관리 화면** (menu_id: 202): - `contract_info` 테이블에 `customer_type` 컬럼이 있다면 - 동일한 카테고리 값 사용 가능 ✅ - **이유**: 202와 204도 형제 메뉴 **주문관리 화면** (menu_id: 203): - `order_info` 테이블에 `customer_type` 컬럼이 있다면 - 동일한 카테고리 값 사용 가능 ✅ - **이유**: 203과 204도 형제 메뉴 ##### ❌ 다른 부모 메뉴에서 사용 불가 **구매관리 > 발주관리** (parent_id: 300): - `purchase_orders` 테이블에 `customer_type` 컬럼이 있어도 - 영업관리의 카테고리는 표시되지 않음 ❌ - **이유**: 다른 부모 메뉴이므로 스코프가 다름 - 구매관리는 자체 카테고리를 별도로 생성해야 함 --- ## 📝 구현 순서 ### Phase 1: 데이터베이스 마이그레이션 (30분) 1. ✅ 마이그레이션 파일 작성 (`047_add_menu_id_to_category_values.sql`) 2. ⏳ DB 마이그레이션 실행 3. ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요) ### Phase 2: 백엔드 구현 (2-3시간) 4. ⏳ `menuService.ts`에 `getSiblingMenuIds()` 함수 추가 5. ⏳ `tableCategoryValueService.ts`에 menu_id 로직 추가 - `getCategoryValues()` 메서드에 menuId 파라미터 추가 - `addCategoryValue()` 메서드에 menuId 파라미터 추가 6. ⏳ `tableCategoryValueController.ts` 수정 - 쿼리 파라미터에서 menuId 추출 - 서비스 호출 시 menuId 전달 7. ⏳ 백엔드 테스트 ### Phase 3: 프론트엔드 API 클라이언트 (30분) 8. ⏳ `frontend/lib/api/tableCategoryValue.ts` 수정 - `getCategoryValues()` 함수에 menuId 파라미터 추가 - `addCategoryValue()` 함수에 menuId 파라미터 추가 ### Phase 4: 프론트엔드 컴포넌트 (2-3시간) 9. ⏳ `CategoryColumnList.tsx` 수정 - props에 `menuId` 추가 - `getCategoryValues()` 호출 시 menuId 전달 10. ⏳ `CategoryValueManager.tsx` 수정 - props에 `menuId` 추가 - 모든 API 호출 시 menuId 전달 11. ⏳ `CategoryWidget.tsx` 수정 또는 신규 생성 - `menuId` prop 추가 - 하위 컴포넌트에 menuId 전달 ### Phase 5: 화면관리 시스템 통합 (1-2시간) 12. ⏳ 화면 정보에서 menuId 추출 로직 추가 13. ⏳ CategoryWidget에 menuId 전달 14. ⏳ 카테고리 관리 화면 테스트 ### Phase 6: 테스트 및 문서화 (1시간) 15. ⏳ 전체 플로우 테스트 16. ⏳ 메뉴 스코프 동작 검증 17. ⏳ 사용 가이드 작성 --- ## 🧪 테스트 체크리스트 ### 백엔드 테스트 - [ ] `getSiblingMenuIds()` 함수가 올바른 형제 메뉴 반환 - [ ] 최상위 메뉴의 경우 자기 자신만 반환 - [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함 - [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음 - [ ] 멀티테넌시 필터링 정상 작동 ### 프론트엔드 테스트 - [ ] 카테고리 컬럼 목록 정상 표시 - [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함) - [ ] 카테고리 값 추가 시 menuId 포함 - [ ] 카테고리 값 수정/삭제 정상 작동 ### 통합 테스트 - [ ] 영업관리 > 공통코드 관리에서 카테고리 생성 - [ ] 영업관리 > 고객관리에서 카테고리 사용 가능 - [ ] 영업관리 > 계약관리에서 카테고리 사용 가능 - [ ] 구매관리에서는 영업관리 카테고리 사용 불가 --- ## 📦 예상 소요 시간 | Phase | 작업 내용 | 예상 시간 | | ---------------- | ------------------- | ------------ | | Phase 1 | DB 마이그레이션 | 30분 | | Phase 2 | 백엔드 구현 | 2-3시간 | | Phase 3 | API 클라이언트 | 30분 | | Phase 4 | 프론트엔드 컴포넌트 | 2-3시간 | | Phase 5 | 화면관리 통합 | 1-2시간 | | Phase 6 | 테스트 및 문서 | 1시간 | | **총 예상 시간** | | **7-11시간** | --- ## 💡 이점 ### 1. 메뉴별 독립 관리 - 영업관리, 구매관리, 생산관리 등 각 부서별 카테고리 독립 관리 - 부서 간 카테고리 충돌 방지 ### 2. 형제 메뉴 간 공유 - 같은 부서의 화면들이 카테고리 공유 - 중복 생성 불필요 ### 3. 테이블 독립성 - 테이블이 달라도 같은 카테고리 사용 가능 - 테이블 구조 변경에 영향 없음 ### 4. 직관적인 관리 - 메뉴 구조가 곧 카테고리 스코프 - 이해하기 쉬운 권한 체계 --- ## 🚀 다음 단계 ### 1. 계획 승인 후 즉시 구현 시작 이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다. ### 2. 채번규칙 시스템도 동일하게 전환 카테고리 시스템 전환이 완료되면, 채번규칙 시스템도 동일한 메뉴 기반 스코프로 전환합니다. ### 3. 공통 유틸리티 함수 재사용 `getSiblingMenuIds()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다. --- 이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다. 바로 구현을 시작할까요?