Files
vexplor/카테고리_시스템_재구현_계획서.md
2025-11-05 15:23:57 +09:00

667 lines
16 KiB
Markdown

# 카테고리 시스템 재구현 계획서
## 기존 구조의 문제점
### ❌ 잘못 이해한 부분
1. **테이블 타입 관리에서 직접 카테고리 값 관리**
- 카테고리가 전역으로 관리됨
- 메뉴별 스코프가 없음
2. **모든 메뉴에서 사용 가능한 전역 카테고리**
- 구매관리에서 만든 카테고리를 영업관리에서도 사용 가능
- 메뉴 간 격리가 안됨
## 올바른 구조
### ✅ 메뉴 계층 기반 카테고리 스코프
```
구매관리 (2레벨 메뉴, menu_id: 100)
├── 발주 관리 (menu_id: 101)
├── 입고 관리 (menu_id: 102)
├── 카테고리 관리 (menu_id: 103) ← 여기서 카테고리 생성 (menuId = 103)
└── 거래처 관리 (menu_id: 104)
```
**카테고리 스코프 규칙**:
- 카테고리 관리 화면의 `menu_id = 103`으로 카테고리 생성
- 이 카테고리는 **같은 부모를 가진 형제 메뉴** (101, 102, 103, 104)에서만 사용 가능
- 다른 2레벨 메뉴 (예: 영업관리)의 하위에서는 사용 불가
### ✅ 화면관리 시스템 통합
```
화면 편집기
├── 위젯 팔레트
│ ├── 텍스트 입력
│ ├── 코드 선택
│ ├── 엔티티 조인
│ └── 카테고리 관리 ← 신규 위젯
└── 캔버스
└── 카테고리 관리 위젯 드래그앤드롭
├── 좌측: 현재 화면 테이블의 카테고리 컬럼 목록
└── 우측: 선택된 컬럼의 카테고리 값 관리
```
---
## 데이터베이스 구조
### table_column_category_values 테이블
```sql
CREATE TABLE table_column_category_values (
value_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
column_name VARCHAR(100) NOT NULL,
-- 값 정보
value_code VARCHAR(50) NOT NULL,
value_label VARCHAR(100) NOT NULL,
value_order INTEGER DEFAULT 0,
-- 계층 구조
parent_value_id INTEGER,
depth INTEGER DEFAULT 1,
-- 추가 정보
description TEXT,
color VARCHAR(20),
icon VARCHAR(50),
is_active BOOLEAN DEFAULT true,
is_default BOOLEAN DEFAULT false,
-- 멀티테넌시
company_code VARCHAR(20) NOT NULL,
-- 메뉴 스코프 (핵심!)
menu_id INTEGER NOT NULL,
-- 메타 정보
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
FOREIGN KEY (company_code) REFERENCES company_mng(company_code),
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id),
FOREIGN KEY (parent_value_id) REFERENCES table_column_category_values(value_id),
UNIQUE (table_name, column_name, value_code, menu_id, company_code)
);
```
**변경사항**:
-`menu_id` 컬럼 추가 (필수)
- ✅ 외래키: `menu_info(menu_id)`
- ✅ UNIQUE 제약조건에 `menu_id` 추가
---
## 백엔드 구현
### 1. 메뉴 스코프 로직
#### 메뉴 계층 구조 조회
```typescript
/**
* 메뉴의 형제 메뉴 ID 목록 조회
* (같은 부모를 가진 메뉴들)
*/
async function getSiblingMenuIds(menuId: number): Promise<number[]> {
const query = `
WITH RECURSIVE menu_tree AS (
-- 현재 메뉴
SELECT menu_id, parent_id, 0 AS level
FROM menu_info
WHERE menu_id = $1
UNION ALL
-- 부모로 올라가기
SELECT m.menu_id, m.parent_id, mt.level + 1
FROM menu_info m
INNER JOIN menu_tree mt ON m.menu_id = mt.parent_id
)
-- 현재 메뉴의 직접 부모 찾기
SELECT parent_id FROM menu_tree WHERE level = 1
`;
const parentResult = await pool.query(query, [menuId]);
if (parentResult.rows.length === 0) {
// 최상위 메뉴인 경우 자기 자신만 반환
return [menuId];
}
const parentId = parentResult.rows[0].parent_id;
// 같은 부모를 가진 형제 메뉴들 조회
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 엔드포인트 수정
#### 기존 API 문제점
```typescript
// ❌ 잘못된 방식: menu_id 없이 조회
GET /api/table-categories/:tableName/:columnName/values
```
#### 올바른 API
```typescript
// ✅ 올바른 방식: menu_id로 필터링
GET /api/table-categories/:tableName/:columnName/values?menuId=103
```
**쿼리 로직**:
```typescript
async getCategoryValues(
tableName: string,
columnName: string,
menuId: number,
companyCode: string,
includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
// 1. 메뉴 스코프 확인: 형제 메뉴들의 카테고리도 포함
const siblingMenuIds = await this.getSiblingMenuIds(menuId);
// 2. 카테고리 값 조회
const query = `
SELECT *
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_id = ANY($3) -- 형제 메뉴들의 카테고리 포함
AND (company_code = $4 OR company_code = '*')
${!includeInactive ? 'AND is_active = true' : ''}
ORDER BY value_order, value_label
`;
const result = await pool.query(query, [
tableName,
columnName,
siblingMenuIds,
companyCode,
]);
return result.rows;
}
```
### 3. 카테고리 추가 시 menu_id 저장
```typescript
async addCategoryValue(
value: TableCategoryValue,
menuId: number,
companyCode: string,
userId: string
): Promise<TableCategoryValue> {
const query = `
INSERT INTO table_column_category_values (
table_name, column_name,
value_code, value_label, value_order,
description, color, icon,
is_active, is_default,
menu_id, company_code,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const result = await pool.query(query, [
value.tableName,
value.columnName,
value.valueCode,
value.valueLabel,
value.valueOrder || 0,
value.description,
value.color,
value.icon,
value.isActive !== false,
value.isDefault || false,
menuId, // ← 카테고리 관리 화면의 menu_id
companyCode,
userId,
]);
return result.rows[0];
}
```
---
## 프론트엔드 구현
### 1. 화면관리 위젯: CategoryWidget
```typescript
// frontend/components/screen/widgets/CategoryWidget.tsx
interface CategoryWidgetProps {
widgetId: string;
config: CategoryWidgetConfig;
menuId: number; // 현재 화면의 menuId
tableName: string; // 현재 화면의 테이블
}
export function CategoryWidget({
widgetId,
config,
menuId,
tableName,
}: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
return (
<div className="flex h-full gap-6">
{/* 좌측: 카테고리 컬럼 리스트 */}
<div className="w-[30%] border-r pr-6">
<CategoryColumnList
tableName={tableName}
menuId={menuId}
selectedColumn={selectedColumn}
onColumnSelect={setSelectedColumn}
/>
</div>
{/* 우측: 카테고리 값 관리 */}
<div className="w-[70%]">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
columnName={selectedColumn}
menuId={menuId}
/>
) : (
<EmptyState message="좌측에서 카테고리 컬럼을 선택하세요" />
)}
</div>
</div>
);
}
```
### 2. 좌측 패널: CategoryColumnList
```typescript
// frontend/components/table-category/CategoryColumnList.tsx
interface CategoryColumnListProps {
tableName: string;
menuId: number;
selectedColumn: string | null;
onColumnSelect: (columnName: string) => void;
}
export function CategoryColumnList({
tableName,
menuId,
selectedColumn,
onColumnSelect,
}: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
useEffect(() => {
loadCategoryColumns();
}, [tableName, menuId]);
const loadCategoryColumns = async () => {
// table_type_columns에서 input_type = 'category'인 컬럼 조회
const response = await apiClient.get(
`/table-management/tables/${tableName}/columns`
);
const categoryColumns = response.data.columns.filter(
(col: any) => col.inputType === "category"
);
setColumns(categoryColumns);
};
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold"> </h3>
<div className="space-y-2">
{columns.map((column) => (
<div
key={column.columnName}
onClick={() => onColumnSelect(column.columnName)}
className={`cursor-pointer rounded-lg border p-4 transition-all ${
selectedColumn === column.columnName
? "border-primary bg-primary/10"
: "hover:bg-muted/50"
}`}
>
<h4 className="text-sm font-semibold">{column.columnLabel}</h4>
<p className="text-xs text-muted-foreground mt-1">
{column.columnName}
</p>
</div>
))}
</div>
</div>
);
}
```
### 3. 우측 패널: CategoryValueManager (수정)
```typescript
// frontend/components/table-category/CategoryValueManager.tsx
interface CategoryValueManagerProps {
tableName: string;
columnName: string;
menuId: number; // ← 추가
columnLabel?: string;
}
export function CategoryValueManager({
tableName,
columnName,
menuId,
columnLabel,
}: CategoryValueManagerProps) {
const [values, setValues] = useState<TableCategoryValue[]>([]);
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName, menuId]);
const loadCategoryValues = async () => {
const response = await getCategoryValues(
tableName,
columnName,
menuId // ← menuId 전달
);
if (response.success && response.data) {
setValues(response.data);
}
};
const handleAddValue = async (newValue: TableCategoryValue) => {
const response = await addCategoryValue({
...newValue,
tableName,
columnName,
menuId, // ← menuId 포함
});
if (response.success) {
loadCategoryValues();
toast.success("카테고리 값이 추가되었습니다");
}
};
// ... 나머지 CRUD 로직
}
```
### 4. 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, includeInactive }, // ← menuId 쿼리 파라미터
});
return response.data;
} catch (error: any) {
console.error("카테고리 값 조회 실패:", error);
return { success: false, error: error.message };
}
}
```
---
## 화면관리 시스템 통합
### 1. ComponentType에 추가
```typescript
// frontend/types/screen.ts
export type ComponentType =
| "text-input"
| "code-select"
| "entity-join"
| "category-manager" // ← 신규
| "number-input"
| ...
```
### 2. 위젯 팔레트에 추가
```typescript
// frontend/components/screen/WidgetPalette.tsx
const WIDGET_CATEGORIES = {
input: [
{ type: "text-input", label: "텍스트 입력", icon: Type },
{ type: "number-input", label: "숫자 입력", icon: Hash },
// ...
],
reference: [
{ type: "code-select", label: "코드 선택", icon: Code },
{ type: "entity-join", label: "엔티티 조인", icon: Database },
{ type: "category-manager", label: "카테고리 관리", icon: FolderTree }, // ← 신규
],
// ...
};
```
### 3. RealtimePreview에 렌더링 추가
```typescript
// frontend/components/screen/RealtimePreview.tsx
function renderWidget(widget: ScreenWidget) {
switch (widget.type) {
case "text-input":
return <TextInputWidget {...widget} />;
case "code-select":
return <CodeSelectWidget {...widget} />;
case "category-manager": // ← 신규
return (
<CategoryWidget
widgetId={widget.id}
config={widget.config}
menuId={currentScreen.menuId}
tableName={currentScreen.tableName}
/>
);
// ...
}
}
```
---
## 테이블 타입 관리 통합 제거
### 기존 코드 제거
1. **`app/(main)/admin/tableMng/page.tsx`에서 제거**:
- "카테고리 값 관리" 버튼 제거
- CategoryValueManagerDialog import 제거
- 관련 상태 및 핸들러 제거
2. **`CategoryValueManagerDialog.tsx` 삭제**:
- Dialog 래퍼 컴포넌트 삭제
**이유**: 카테고리는 화면관리 시스템에서만 관리해야 함
---
## 사용 시나리오
### 1. 카테고리 관리 화면 생성
1. **메뉴 등록**: 구매관리 > 카테고리 관리 (menu_id: 103)
2. **화면 생성**: 카테고리 관리 화면 생성
3. **테이블 연결**: 테이블 선택 (예: `purchase_orders`)
4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭
### 2. 카테고리 값 등록
1. **좌측 패널**: `purchase_orders` 테이블의 카테고리 컬럼 목록 표시
- `order_type` (발주 유형)
- `order_status` (발주 상태)
- `priority` (우선순위)
2. **컬럼 선택**: `order_type` 클릭
3. **우측 패널**: 카테고리 값 관리
- "추가" 버튼 클릭
- 코드: `MATERIAL`, 라벨: `자재 발주`
- 색상: `#3b82f6`, 설명: `생산 자재 발주`
- **저장 시 `menu_id = 103`으로 자동 저장됨**
### 3. 다른 화면에서 카테고리 사용
1. **발주 관리 화면** (menu_id: 101, 형제 메뉴)
- `order_type` 컬럼을 Code Select 위젯으로 배치
- 드롭다운에 `자재 발주`, `외주 발주` 등 표시됨 ✅
2. **영업관리 > 주문 관리** (다른 2레벨 메뉴)
- 같은 `order_type` 컬럼이 있어도
- 구매관리의 카테고리는 표시되지 않음 ❌
- 영업관리 자체 카테고리만 사용 가능
---
## 마이그레이션 작업
### 1. DB 마이그레이션 실행
```bash
psql -U postgres -d plm < db/migrations/036_create_table_column_category_values.sql
```
### 2. 기존 카테고리 데이터 마이그레이션
```sql
-- 기존 데이터에 menu_id 추가 (임시로 1번 메뉴로 설정)
ALTER TABLE table_column_category_values
ADD COLUMN IF NOT EXISTS menu_id INTEGER DEFAULT 1;
-- 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
```
---
## 구현 순서
### Phase 1: DB 및 백엔드 (1-2시간)
1. ✅ DB 마이그레이션: `menu_id` 컬럼 추가
2. ⏳ 백엔드 타입 수정: `menuId` 필드 추가
3. ⏳ 백엔드 서비스: 메뉴 스코프 로직 구현
4. ⏳ API 컨트롤러: `menuId` 파라미터 추가
### Phase 2: 프론트엔드 컴포넌트 (2-3시간)
5. ⏳ CategoryWidget 생성 (좌우 분할)
6. ⏳ CategoryColumnList 복원 및 수정
7. ⏳ CategoryValueManager에 `menuId` 추가
8. ⏳ API 클라이언트 수정
### Phase 3: 화면관리 시스템 통합 (1-2시간)
9. ⏳ ComponentType에 `category-manager` 추가
10. ⏳ 위젯 팔레트에 추가
11. ⏳ RealtimePreview 렌더링 추가
12. ⏳ Config Panel 생성
### Phase 4: 정리 (30분)
13. ⏳ 테이블 타입 관리에서 카테고리 Dialog 제거
14. ⏳ 불필요한 파일 제거
15. ⏳ 테스트 및 문서화
---
## 예상 소요 시간
- **Phase 1**: 1-2시간
- **Phase 2**: 2-3시간
- **Phase 3**: 1-2시간
- **Phase 4**: 30분
- **총 예상 시간**: 5-8시간
---
## 완료 체크리스트
### DB
- [ ] `menu_id` 컬럼 추가
- [ ] 외래키 `menu_info(menu_id)` 추가
- [ ] UNIQUE 제약조건에 `menu_id` 추가
- [ ] 인덱스 추가
### 백엔드
- [ ] 타입에 `menuId` 추가
- [ ] `getSiblingMenuIds()` 함수 구현
- [ ] 모든 쿼리에 `menu_id` 필터링 추가
- [ ] API 파라미터에 `menuId` 추가
### 프론트엔드
- [ ] CategoryWidget 생성
- [ ] CategoryColumnList 수정
- [ ] CategoryValueManager에 `menuId` props 추가
- [ ] API 클라이언트 수정
### 화면관리 시스템
- [ ] ComponentType 추가
- [ ] 위젯 팔레트 추가
- [ ] RealtimePreview 렌더링
- [ ] Config Panel 생성
### 정리
- [ ] 테이블 타입 관리 Dialog 제거
- [ ] 불필요한 파일 삭제
- [ ] 테스트
- [ ] 문서 작성
---
지금 바로 구현을 시작할까요?