# 카테고리 시스템 재구현 계획서 ## 기존 구조의 문제점 ### ❌ 잘못 이해한 부분 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 { 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 { // 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 { 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(null); return (
{/* 좌측: 카테고리 컬럼 리스트 */}
{/* 우측: 카테고리 값 관리 */}
{selectedColumn ? ( ) : ( )}
); } ``` ### 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([]); 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 (

카테고리 컬럼

{columns.map((column) => (
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" }`} >

{column.columnLabel}

{column.columnName}

))}
); } ``` ### 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([]); 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 ; case "code-select": return ; case "category-manager": // ← 신규 return ( ); // ... } } ``` --- ## 테이블 타입 관리 통합 제거 ### 기존 코드 제거 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 제거 - [ ] 불필요한 파일 삭제 - [ ] 테스트 - [ ] 문서 작성 --- 지금 바로 구현을 시작할까요?