27 KiB
27 KiB
카테고리 컴포넌트 메뉴 기반 전환 계획서
📋 현재 문제점
테이블 기반 스코프의 한계
현재 상황:
- 카테고리와 채번 컴포넌트가 테이블 기준으로 데이터를 불러옴
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)에서 모두 사용 가능
- 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가
📐 데이터베이스 설계
기존 테이블 수정
-- 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. 메뉴 스코프 로직 추가
형제 메뉴 조회 함수
// 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 수정
서비스 로직 수정
// 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 수정
/**
* 카테고리 값 추가 (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. 컨트롤러 수정
// 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 클라이언트 수정
// 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 컴포넌트 수정
// 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 컴포넌트 수정
// 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 추출
// 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 컴포넌트 (신규 또는 수정)
// 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>
);
}
🔄 기존 데이터 마이그레이션
마이그레이션 스크립트
-- 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단계: 카테고리 관리 화면 생성
- 메뉴 등록: 영업관리 > 공통코드 관리 (menu_id: 204)
- 화면 생성: 화면관리 시스템에서 화면 생성
- 테이블 선택: 영업관리에서 사용할 아무 테이블 (예:
customer_info)- 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐
- 실제 스코프는
menu_id로 결정됨
- 위젯 배치: 카테고리 관리 위젯 드래그앤드롭
3단계: 카테고리 값 등록
-
좌측 패널:
customer_info테이블의 카테고리 컬럼 표시customer_type(고객 유형)customer_grade(고객 등급)
-
컬럼 선택:
customer_type클릭 -
우측 패널: 카테고리 값 관리
- 추가 버튼 클릭
- 코드:
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분)
- ✅ 마이그레이션 파일 작성 (
047_add_menu_id_to_category_values.sql) - ⏳ DB 마이그레이션 실행
- ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요)
Phase 2: 백엔드 구현 (2-3시간)
- ⏳
menuService.ts에getSiblingMenuIds()함수 추가 - ⏳
tableCategoryValueService.ts에 menu_id 로직 추가getCategoryValues()메서드에 menuId 파라미터 추가addCategoryValue()메서드에 menuId 파라미터 추가
- ⏳
tableCategoryValueController.ts수정- 쿼리 파라미터에서 menuId 추출
- 서비스 호출 시 menuId 전달
- ⏳ 백엔드 테스트
Phase 3: 프론트엔드 API 클라이언트 (30분)
- ⏳
frontend/lib/api/tableCategoryValue.ts수정getCategoryValues()함수에 menuId 파라미터 추가addCategoryValue()함수에 menuId 파라미터 추가
Phase 4: 프론트엔드 컴포넌트 (2-3시간)
- ⏳
CategoryColumnList.tsx수정- props에
menuId추가 getCategoryValues()호출 시 menuId 전달
- props에
- ⏳
CategoryValueManager.tsx수정- props에
menuId추가 - 모든 API 호출 시 menuId 전달
- props에
- ⏳
CategoryWidget.tsx수정 또는 신규 생성menuIdprop 추가- 하위 컴포넌트에 menuId 전달
Phase 5: 화면관리 시스템 통합 (1-2시간)
- ⏳ 화면 정보에서 menuId 추출 로직 추가
- ⏳ CategoryWidget에 menuId 전달
- ⏳ 카테고리 관리 화면 테스트
Phase 6: 테스트 및 문서화 (1시간)
- ⏳ 전체 플로우 테스트
- ⏳ 메뉴 스코프 동작 검증
- ⏳ 사용 가이드 작성
🧪 테스트 체크리스트
백엔드 테스트
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() 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다. 바로 구현을 시작할까요?