667 lines
16 KiB
Markdown
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 제거
|
|
- [ ] 불필요한 파일 삭제
|
|
- [ ] 테스트
|
|
- [ ] 문서 작성
|
|
|
|
---
|
|
|
|
지금 바로 구현을 시작할까요?
|