Files
vexplor/카테고리_메뉴기반_전환_계획서.md
2025-11-11 11:37:26 +09:00

978 lines
27 KiB
Markdown

# 카테고리 컴포넌트 메뉴 기반 전환 계획서
## 📋 현재 문제점
### 테이블 기반 스코프의 한계
**현재 상황**:
- 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴
- `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<number[]> {
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<TableCategoryValue[]> {
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<TableCategoryValue> {
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<void> {
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<void> {
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<CategoryColumn[]>([]);
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<TableCategoryValue[]>([]);
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<Screen | null>(null);
// 선택된 화면의 menuId 추출
const currentMenuId = selectedScreen?.menuId;
// CategoryWidget 렌더링 시 menuId 전달
return (
<div>
{/* ... */}
<CategoryWidget
tableName={selectedScreen?.tableName}
menuId={currentMenuId} // ← menuId 전달
/>
</div>
);
}
```
#### CategoryWidget 컴포넌트 (신규 또는 수정)
```typescript
// frontend/components/screen/widgets/CategoryWidget.tsx
interface CategoryWidgetProps {
tableName: string;
menuId: number; // ← 추가
}
export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
const [selectedColumnLabel, setSelectedColumnLabel] = useState<string>("");
const handleColumnSelect = (columnName: string, columnLabel: string) => {
setSelectedColumn(columnName);
setSelectedColumnLabel(columnLabel);
};
return (
<div className="flex h-full gap-6">
{/* 좌측: 카테고리 컬럼 리스트 */}
<div className="w-[30%] border-r pr-6">
<CategoryColumnList
tableName={tableName}
menuId={menuId} // ← menuId 전달
selectedColumn={selectedColumn}
onColumnSelect={handleColumnSelect}
/>
</div>
{/* 우측: 카테고리 값 관리 */}
<div className="w-[70%]">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
columnName={selectedColumn}
menuId={menuId} // ← menuId 전달
columnLabel={selectedColumnLabel}
/>
) : (
<div className="flex items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
</div>
</div>
);
}
```
---
## 🔄 기존 데이터 마이그레이션
### 마이그레이션 스크립트
```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()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
---
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다.
바로 구현을 시작할까요?