674 lines
18 KiB
Markdown
674 lines
18 KiB
Markdown
|
|
# 카테고리 메뉴 스코프 개선 계획서
|
||
|
|
|
||
|
|
## 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<number> {
|
||
|
|
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<CategoryMenuScope[]>([]);
|
||
|
|
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
|
||
|
|
|
||
|
|
// 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 (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<Label>적용할 메뉴 선택</Label>
|
||
|
|
<div className="border rounded-lg p-4 space-y-2 max-h-60 overflow-y-auto">
|
||
|
|
{menuScopes.map((scope) => (
|
||
|
|
<div key={scope.menuObjid} className="flex items-center gap-2">
|
||
|
|
<Checkbox
|
||
|
|
checked={selectedMenus.includes(scope.menuObjid)}
|
||
|
|
onCheckedChange={(checked) => {
|
||
|
|
if (checked) {
|
||
|
|
setSelectedMenus([...selectedMenus, scope.menuObjid]);
|
||
|
|
} else {
|
||
|
|
setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid));
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<span className="text-sm">
|
||
|
|
{scope.parentMenuName} → {scope.menuName}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{selectedMenus.length === 0 && (
|
||
|
|
<p className="text-xs text-destructive">
|
||
|
|
최소 하나 이상의 메뉴를 선택해주세요
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 카테고리 값 추가 UI */}
|
||
|
|
<CategoryValueEditor
|
||
|
|
onSave={handleSaveCategoryValue}
|
||
|
|
disabled={selectedMenus.length === 0}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 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<ApiResponse<CategoryColumn[]>> {
|
||
|
|
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 (
|
||
|
|
<Alert variant="destructive">
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
<AlertTitle>메뉴 스코프 미설정</AlertTitle>
|
||
|
|
<AlertDescription>
|
||
|
|
{unassignedCategories.length}개의 카테고리 값에 메뉴가 설정되지 않았습니다.
|
||
|
|
<br />
|
||
|
|
각 카테고리가 어떤 메뉴에서 사용될지 설정해주세요.
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return <div>...</div>;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 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 <commit-hash>
|
||
|
|
```
|
||
|
|
|
||
|
|
### 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에서 메뉴 선택 기능 추가
|
||
|
|
|
||
|
|
이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.
|
||
|
|
|