# 카테고리 시스템 구현 계획서 > **작성일**: 2025-11-04 > **목적**: 메뉴별로 별도 관리되는 카테고리 코드 시스템 구현 --- ## 1. 개요 ### 1.1 카테고리 시스템이란? **공통코드**와 유사하지만 **메뉴별로 독립적으로 관리**되는 코드 시스템입니다. **주요 특징**: - 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리 - 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계) - 멀티테넌시: 회사별로 독립적으로 관리 - 동적 추가/수정: 관리자가 화면에서 직접 카테고리 추가/수정 가능 ### 1.2 사용 예시 #### 프로젝트 관리 화면 - 프로젝트 유형: 개발, 유지보수, 컨설팅 - 프로젝트 상태: 계획, 진행중, 완료, 보류 - 우선순위: 긴급, 높음, 보통, 낮음 #### 계약관리 화면 - 계약 유형: 판매, 구매, 임대, 용역 - 계약 상태: 작성중, 검토중, 체결, 종료 - 결제 방식: 현금, 카드, 계좌이체, 어음 #### 자산관리 화면 - 자산 분류: IT장비, 비품, 차량, 부동산 - 자산 상태: 정상, 수리중, 폐기, 분실 - 위치: 본사, 지점A, 지점B, 창고 --- ## 2. 데이터베이스 설계 ### 2.1 카테고리 마스터 테이블 ```sql -- 메뉴별 카테고리 마스터 CREATE TABLE IF NOT EXISTS menu_categories ( category_id VARCHAR(50) PRIMARY KEY, -- 카테고리 ID (예: PROJ_TYPE, PROJ_STATUS) category_name VARCHAR(100) NOT NULL, -- 카테고리명 (예: 프로젝트 유형) menu_objid NUMERIC NOT NULL, -- 적용할 메뉴 OBJID description TEXT, -- 설명 -- 설정 allow_custom_values BOOLEAN DEFAULT false, -- 사용자 정의 값 허용 여부 max_depth INTEGER DEFAULT 1, -- 최대 계층 깊이 (1~3) is_multi_select BOOLEAN DEFAULT false, -- 다중 선택 가능 여부 -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, -- 메타 정보 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid) REFERENCES menu_info(objid), CONSTRAINT fk_category_company FOREIGN KEY (company_code) REFERENCES company_info(company_code) ); -- 인덱스 CREATE INDEX idx_menu_categories_menu ON menu_categories(menu_objid); CREATE INDEX idx_menu_categories_company ON menu_categories(company_code); CREATE INDEX idx_menu_categories_scope ON menu_categories(company_code, menu_objid); ``` ### 2.2 카테고리 값 테이블 ```sql -- 카테고리 값 (계층 구조 지원) CREATE TABLE IF NOT EXISTS category_values ( value_id SERIAL PRIMARY KEY, category_id VARCHAR(50) NOT NULL, -- 상위 카테고리 ID value_code VARCHAR(50) NOT NULL, -- 값 코드 (예: DEV, MAINT, CONSULT) value_label VARCHAR(100) NOT NULL, -- 값 라벨 (예: 개발, 유지보수, 컨설팅) value_order INTEGER DEFAULT 0, -- 정렬 순서 -- 계층 구조 parent_value_id INTEGER, -- 상위 값 ID (NULL이면 최상위) depth INTEGER DEFAULT 1, -- 계층 깊이 (1~3) -- 추가 정보 description TEXT, -- 설명 is_active BOOLEAN DEFAULT true, -- 활성화 여부 -- 멀티테넌시 company_code VARCHAR(20) NOT NULL, -- 메타 정보 created_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), CONSTRAINT fk_value_category FOREIGN KEY (category_id) REFERENCES menu_categories(category_id) ON DELETE CASCADE, CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id) REFERENCES category_values(value_id), CONSTRAINT fk_value_company FOREIGN KEY (company_code) REFERENCES company_info(company_code), CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code) ); -- 인덱스 CREATE INDEX idx_category_values_category ON category_values(category_id); CREATE INDEX idx_category_values_parent ON category_values(parent_value_id); CREATE INDEX idx_category_values_company ON category_values(company_code); ``` ### 2.3 샘플 데이터 ```sql -- 샘플 카테고리: 프로젝트 유형 (메뉴 OBJID: 100) INSERT INTO menu_categories ( category_id, category_name, menu_objid, description, max_depth, company_code, created_by ) VALUES ( 'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리', 2, 'COMPANY_A', 'admin' ); -- 프로젝트 유형 값들 INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by) VALUES ('PROJ_TYPE', 'DEV', '개발', 1, 'COMPANY_A', 'admin'), ('PROJ_TYPE', 'MAINT', '유지보수', 2, 'COMPANY_A', 'admin'), ('PROJ_TYPE', 'CONSULT', '컨설팅', 3, 'COMPANY_A', 'admin'); -- 샘플 카테고리: 프로젝트 상태 (메뉴 OBJID: 100) INSERT INTO menu_categories ( category_id, category_name, menu_objid, description, company_code, created_by ) VALUES ( 'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태', 'COMPANY_A', 'admin' ); -- 프로젝트 상태 값들 INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by) VALUES ('PROJ_STATUS', 'PLAN', '계획', 1, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'PROGRESS', '진행중', 2, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'COMPLETE', '완료', 3, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'HOLD', '보류', 4, 'COMPANY_A', 'admin'); -- 샘플: 계층 구조 카테고리 (지역 → 도시 → 구) INSERT INTO menu_categories ( category_id, category_name, menu_objid, description, max_depth, company_code, created_by ) VALUES ( 'REGION', '지역', 101, '지역/도시/구 계층 구조', 3, 'COMPANY_A', 'admin' ); -- 1단계: 지역 INSERT INTO category_values (category_id, value_code, value_label, depth, value_order, company_code) VALUES ('REGION', 'SEOUL', '서울특별시', 1, 1, 'COMPANY_A'), ('REGION', 'BUSAN', '부산광역시', 1, 2, 'COMPANY_A'); -- 2단계: 서울의 구 INSERT INTO category_values (category_id, value_code, value_label, parent_value_id, depth, value_order, company_code) SELECT 'REGION', 'GANGNAM', '강남구', value_id, 2, 1, 'COMPANY_A' FROM category_values WHERE value_code = 'SEOUL' AND category_id = 'REGION'; INSERT INTO category_values (category_id, value_code, value_label, parent_value_id, depth, value_order, company_code) SELECT 'REGION', 'SONGPA', '송파구', value_id, 2, 2, 'COMPANY_A' FROM category_values WHERE value_code = 'SEOUL' AND category_id = 'REGION'; ``` --- ## 3. 백엔드 구현 ### 3.1 타입 정의 ```typescript // backend-node/src/types/category.ts export interface CategoryConfig { categoryId: string; categoryName: string; menuObjid: number; description?: string; // 설정 allowCustomValues?: boolean; maxDepth?: number; isMultiSelect?: boolean; // 멀티테넌시 companyCode?: string; // 메타 createdAt?: string; updatedAt?: string; createdBy?: string; } export interface CategoryValue { valueId?: number; categoryId: string; valueCode: string; valueLabel: string; valueOrder?: number; // 계층 parentValueId?: number; depth?: number; // 추가 정보 description?: string; isActive?: boolean; // 하위 항목 (조회 시) children?: CategoryValue[]; // 멀티테넌시 companyCode?: string; // 메타 createdAt?: string; createdBy?: string; } ``` ### 3.2 서비스 레이어 ```typescript // backend-node/src/services/categoryService.ts import { getPool } from "../config/database"; import logger from "../config/logger"; import { CategoryConfig, CategoryValue } from "../types/category"; class CategoryService { /** * 메뉴별 카테고리 목록 조회 */ async getCategoriesByMenu( companyCode: string, menuObjid: number ): Promise { try { logger.info("메뉴별 카테고리 조회 시작", { companyCode, menuObjid }); const pool = getPool(); const query = ` SELECT category_id AS "categoryId", category_name AS "categoryName", menu_objid AS "menuObjid", description, allow_custom_values AS "allowCustomValues", max_depth AS "maxDepth", is_multi_select AS "isMultiSelect", company_code AS "companyCode", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" FROM menu_categories WHERE (company_code = $1 OR company_code = '*') AND menu_objid = $2 ORDER BY category_name `; const result = await pool.query(query, [companyCode, menuObjid]); logger.info(`메뉴별 카테고리 조회 완료: ${result.rows.length}개`, { companyCode, menuObjid, }); return result.rows; } catch (error: any) { logger.error(`메뉴별 카테고리 조회 중 에러: ${error.message}`); throw error; } } /** * 카테고리별 값 조회 (계층 구조) */ async getCategoryValues( categoryId: string, companyCode: string ): Promise { try { logger.info("카테고리 값 조회 시작", { categoryId, companyCode }); const pool = getPool(); const query = ` SELECT value_id AS "valueId", category_id AS "categoryId", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, is_active AS "isActive", company_code AS "companyCode", created_at AS "createdAt", created_by AS "createdBy" FROM category_values WHERE category_id = $1 AND (company_code = $2 OR company_code = '*') AND is_active = true ORDER BY value_order, value_label `; const result = await pool.query(query, [categoryId, companyCode]); // 계층 구조로 변환 const values = this.buildHierarchy(result.rows); logger.info(`카테고리 값 조회 완료: ${result.rows.length}개`, { categoryId, companyCode, }); return values; } catch (error: any) { logger.error(`카테고리 값 조회 중 에러: ${error.message}`); throw error; } } /** * 카테고리 생성 */ async createCategory( config: CategoryConfig, companyCode: string, userId: string ): Promise { const pool = getPool(); const client = await pool.connect(); try { await client.query("BEGIN"); const insertQuery = ` INSERT INTO menu_categories ( category_id, category_name, menu_objid, description, allow_custom_values, max_depth, is_multi_select, company_code, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING category_id AS "categoryId", category_name AS "categoryName", menu_objid AS "menuObjid", description, allow_custom_values AS "allowCustomValues", max_depth AS "maxDepth", is_multi_select AS "isMultiSelect", company_code AS "companyCode", created_at AS "createdAt", created_by AS "createdBy" `; const result = await client.query(insertQuery, [ config.categoryId, config.categoryName, config.menuObjid, config.description || null, config.allowCustomValues || false, config.maxDepth || 1, config.isMultiSelect || false, companyCode, userId, ]); await client.query("COMMIT"); logger.info("카테고리 생성 완료", { categoryId: config.categoryId, companyCode, }); return result.rows[0]; } catch (error: any) { await client.query("ROLLBACK"); logger.error(`카테고리 생성 중 에러: ${error.message}`); throw error; } finally { client.release(); } } /** * 카테고리 값 추가 */ async addCategoryValue( value: CategoryValue, companyCode: string, userId: string ): Promise { const pool = getPool(); try { const insertQuery = ` INSERT INTO category_values ( category_id, value_code, value_label, value_order, parent_value_id, depth, description, is_active, company_code, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING value_id AS "valueId", category_id AS "categoryId", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, is_active AS "isActive", company_code AS "companyCode", created_at AS "createdAt", created_by AS "createdBy" `; const result = await pool.query(insertQuery, [ value.categoryId, value.valueCode, value.valueLabel, value.valueOrder || 0, value.parentValueId || null, value.depth || 1, value.description || null, value.isActive !== false, companyCode, userId, ]); logger.info("카테고리 값 추가 완료", { valueId: result.rows[0].valueId, categoryId: value.categoryId, }); return result.rows[0]; } catch (error: any) { logger.error(`카테고리 값 추가 중 에러: ${error.message}`); throw error; } } /** * 카테고리 값 수정 */ async updateCategoryValue( valueId: number, updates: Partial, companyCode: string ): Promise { const pool = getPool(); try { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (updates.valueLabel !== undefined) { setClauses.push(`value_label = $${paramIndex++}`); values.push(updates.valueLabel); } if (updates.valueOrder !== undefined) { setClauses.push(`value_order = $${paramIndex++}`); values.push(updates.valueOrder); } if (updates.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); values.push(updates.description); } if (updates.isActive !== undefined) { setClauses.push(`is_active = $${paramIndex++}`); values.push(updates.isActive); } setClauses.push(`updated_at = NOW()`); values.push(valueId, companyCode); const updateQuery = ` UPDATE category_values SET ${setClauses.join(", ")} WHERE value_id = $${paramIndex++} AND (company_code = $${paramIndex++} OR company_code = '*') RETURNING value_id AS "valueId", category_id AS "categoryId", value_code AS "valueCode", value_label AS "valueLabel", value_order AS "valueOrder", parent_value_id AS "parentValueId", depth, description, is_active AS "isActive" `; const result = await pool.query(updateQuery, values); if (result.rowCount === 0) { throw new Error("카테고리 값을 찾을 수 없습니다"); } logger.info("카테고리 값 수정 완료", { valueId, companyCode }); return result.rows[0]; } catch (error: any) { logger.error(`카테고리 값 수정 중 에러: ${error.message}`); throw error; } } /** * 카테고리 값 삭제 (비활성화) */ async deleteCategoryValue( valueId: number, companyCode: string ): Promise { const pool = getPool(); try { // 하위 값이 있는지 확인 const checkQuery = ` SELECT COUNT(*) as count FROM category_values WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*') AND is_active = true `; const checkResult = await pool.query(checkQuery, [valueId, companyCode]); if (parseInt(checkResult.rows[0].count) > 0) { throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } // 비활성화 const deleteQuery = ` UPDATE category_values SET is_active = false, updated_at = NOW() WHERE value_id = $1 AND (company_code = $2 OR company_code = '*') `; await pool.query(deleteQuery, [valueId, companyCode]); logger.info("카테고리 값 삭제(비활성화) 완료", { valueId, companyCode, }); } catch (error: any) { logger.error(`카테고리 값 삭제 중 에러: ${error.message}`); throw error; } } /** * 계층 구조 변환 헬퍼 함수 */ private buildHierarchy( values: CategoryValue[], parentId: number | null = null ): CategoryValue[] { return values .filter((v) => v.parentValueId === parentId) .map((v) => ({ ...v, children: this.buildHierarchy(values, v.valueId!), })); } } export default new CategoryService(); ``` ### 3.3 컨트롤러 레이어 ```typescript // backend-node/src/controllers/categoryController.ts import { Request, Response } from "express"; import categoryService from "../services/categoryService"; import logger from "../config/logger"; /** * 메뉴별 카테고리 목록 조회 */ export const getCategoriesByMenu = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const menuObjid = parseInt(req.params.menuObjid); if (isNaN(menuObjid)) { return res.status(400).json({ success: false, message: "유효하지 않은 메뉴 ID입니다", }); } const categories = await categoryService.getCategoriesByMenu( companyCode, menuObjid ); return res.json({ success: true, data: categories, }); } catch (error: any) { logger.error(`메뉴별 카테고리 조회 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 조회 중 오류가 발생했습니다", error: error.message, }); } }; /** * 카테고리 값 조회 */ export const getCategoryValues = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const { categoryId } = req.params; const values = await categoryService.getCategoryValues( categoryId, companyCode ); return res.json({ success: true, data: values, }); } catch (error: any) { logger.error(`카테고리 값 조회 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 값 조회 중 오류가 발생했습니다", error: error.message, }); } }; /** * 카테고리 생성 */ export const createCategory = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const config = req.body; const newCategory = await categoryService.createCategory( config, companyCode, userId ); return res.status(201).json({ success: true, data: newCategory, }); } catch (error: any) { logger.error(`카테고리 생성 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 생성 중 오류가 발생했습니다", error: error.message, }); } }; /** * 카테고리 값 추가 */ export const addCategoryValue = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; const value = req.body; const newValue = await categoryService.addCategoryValue( value, companyCode, userId ); return res.status(201).json({ success: true, data: newValue, }); } catch (error: any) { logger.error(`카테고리 값 추가 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 값 추가 중 오류가 발생했습니다", error: error.message, }); } }; /** * 카테고리 값 수정 */ export const updateCategoryValue = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const valueId = parseInt(req.params.valueId); const updates = req.body; if (isNaN(valueId)) { return res.status(400).json({ success: false, message: "유효하지 않은 값 ID입니다", }); } const updatedValue = await categoryService.updateCategoryValue( valueId, updates, companyCode ); return res.json({ success: true, data: updatedValue, }); } catch (error: any) { logger.error(`카테고리 값 수정 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 값 수정 중 오류가 발생했습니다", error: error.message, }); } }; /** * 카테고리 값 삭제 */ export const deleteCategoryValue = async (req: Request, res: Response) => { try { const companyCode = req.user!.companyCode; const valueId = parseInt(req.params.valueId); if (isNaN(valueId)) { return res.status(400).json({ success: false, message: "유효하지 않은 값 ID입니다", }); } await categoryService.deleteCategoryValue(valueId, companyCode); return res.json({ success: true, message: "카테고리 값이 삭제되었습니다", }); } catch (error: any) { logger.error(`카테고리 값 삭제 실패: ${error.message}`); return res.status(500).json({ success: false, message: "카테고리 값 삭제 중 오류가 발생했습니다", error: error.message, }); } }; ``` ### 3.4 라우트 설정 ```typescript // backend-node/src/routes/categoryRoutes.ts import { Router } from "express"; import * as categoryController from "../controllers/categoryController"; import { authenticate } from "../middleware/authMiddleware"; const router = Router(); // 모든 라우트에 인증 미들웨어 적용 router.use(authenticate); // 메뉴별 카테고리 목록 조회 router.get("/menu/:menuObjid", categoryController.getCategoriesByMenu); // 카테고리 값 조회 router.get("/:categoryId/values", categoryController.getCategoryValues); // 카테고리 생성 router.post("/", categoryController.createCategory); // 카테고리 값 추가 router.post("/values", categoryController.addCategoryValue); // 카테고리 값 수정 router.put("/values/:valueId", categoryController.updateCategoryValue); // 카테고리 값 삭제 router.delete("/values/:valueId", categoryController.deleteCategoryValue); export default router; ``` ```typescript // backend-node/src/app.ts에 추가 import categoryRoutes from "./routes/categoryRoutes"; // 라우트 등록 app.use("/api/categories", categoryRoutes); ``` --- ## 4. 프론트엔드 구현 ### 4.1 타입 정의 ```typescript // frontend/types/category.ts export interface CategoryConfig { categoryId: string; categoryName: string; menuObjid: number; description?: string; // 설정 allowCustomValues?: boolean; maxDepth?: number; isMultiSelect?: boolean; // 멀티테넌시 companyCode?: string; // 메타 createdAt?: string; updatedAt?: string; createdBy?: string; } export interface CategoryValue { valueId?: number; categoryId: string; valueCode: string; valueLabel: string; valueOrder?: number; // 계층 parentValueId?: number; depth?: number; // 추가 정보 description?: string; isActive?: boolean; // 하위 항목 children?: CategoryValue[]; // 멀티테넌시 companyCode?: string; // 메타 createdAt?: string; createdBy?: string; } ``` ### 4.2 API 클라이언트 ```typescript // frontend/lib/api/category.ts import apiClient from "./client"; import { CategoryConfig, CategoryValue } from "@/types/category"; /** * 메뉴별 카테고리 목록 조회 */ export async function getCategoriesByMenu(menuObjid: number) { try { const response = await apiClient.get<{ success: boolean; data: CategoryConfig[]; }>(`/api/categories/menu/${menuObjid}`); return response.data; } catch (error: any) { console.error("카테고리 목록 조회 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 값 조회 */ export async function getCategoryValues(categoryId: string) { try { const response = await apiClient.get<{ success: boolean; data: CategoryValue[]; }>(`/api/categories/${categoryId}/values`); return response.data; } catch (error: any) { console.error("카테고리 값 조회 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 생성 */ export async function createCategory(config: CategoryConfig) { try { const response = await apiClient.post<{ success: boolean; data: CategoryConfig; }>("/api/categories", config); return response.data; } catch (error: any) { console.error("카테고리 생성 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 값 추가 */ export async function addCategoryValue(value: CategoryValue) { try { const response = await apiClient.post<{ success: boolean; data: CategoryValue; }>("/api/categories/values", value); return response.data; } catch (error: any) { console.error("카테고리 값 추가 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 값 수정 */ export async function updateCategoryValue( valueId: number, updates: Partial ) { try { const response = await apiClient.put<{ success: boolean; data: CategoryValue; }>(`/api/categories/values/${valueId}`, updates); return response.data; } catch (error: any) { console.error("카테고리 값 수정 실패:", error); return { success: false, error: error.message }; } } /** * 카테고리 값 삭제 */ export async function deleteCategoryValue(valueId: number) { try { const response = await apiClient.delete<{ success: boolean; message: string; }>(`/api/categories/values/${valueId}`); return response.data; } catch (error: any) { console.error("카테고리 값 삭제 실패:", error); return { success: false, error: error.message }; } } ``` ### 4.3 웹타입 확장 ```typescript // frontend/types/screen.ts에 추가 export type WebType = | "text" | "number" | "decimal" | "date" | "datetime" | "select" | "entity" | "category" // 신규 추가 | "textarea" | "boolean" | "checkbox" | "radio" | "code" | "file" | "email" | "tel" | "button"; ``` ### 4.4 카테고리 설정 패널 ```typescript // frontend/components/screen/panels/webtype-configs/CategoryTypeConfigPanel.tsx "use client"; import React, { useState, useEffect } from "react"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Plus, X, ChevronRight } from "lucide-react"; import { getCategoriesByMenu, getCategoryValues, addCategoryValue, } from "@/lib/api/category"; import { CategoryConfig, CategoryValue } from "@/types/category"; import { useToast } from "@/hooks/use-toast"; interface CategoryTypeConfigPanelProps { config: any; onUpdate: (updates: any) => void; menuObjid?: number; } export const CategoryTypeConfigPanel: React.FC< CategoryTypeConfigPanelProps > = ({ config, onUpdate, menuObjid }) => { const { toast } = useToast(); const [categories, setCategories] = useState([]); const [selectedCategory, setSelectedCategory] = useState( config.categoryId || "" ); const [categoryValues, setCategoryValues] = useState([]); const [isLoading, setIsLoading] = useState(false); // 신규 값 추가 상태 const [isAddingValue, setIsAddingValue] = useState(false); const [newValueCode, setNewValueCode] = useState(""); const [newValueLabel, setNewValueLabel] = useState(""); // 메뉴별 카테고리 목록 로드 useEffect(() => { if (menuObjid) { loadCategories(); } }, [menuObjid]); // 선택된 카테고리의 값 로드 useEffect(() => { if (selectedCategory) { loadCategoryValues(); } }, [selectedCategory]); const loadCategories = async () => { if (!menuObjid) return; setIsLoading(true); try { const response = await getCategoriesByMenu(menuObjid); if (response.success && response.data) { setCategories(response.data); } } catch (error) { console.error("카테고리 목록 로드 실패:", error); toast({ title: "오류", description: "카테고리 목록을 불러올 수 없습니다", variant: "destructive", }); } finally { setIsLoading(false); } }; const loadCategoryValues = async () => { setIsLoading(true); try { const response = await getCategoryValues(selectedCategory); if (response.success && response.data) { setCategoryValues(response.data); } } catch (error) { console.error("카테고리 값 로드 실패:", error); } finally { setIsLoading(false); } }; const handleCategoryChange = (categoryId: string) => { setSelectedCategory(categoryId); onUpdate({ categoryId, values: [], }); }; const handleAddValue = async () => { if (!newValueCode || !newValueLabel) { toast({ title: "입력 오류", description: "코드와 라벨을 모두 입력해주세요", variant: "destructive", }); return; } try { const newValue: CategoryValue = { categoryId: selectedCategory, valueCode: newValueCode, valueLabel: newValueLabel, valueOrder: categoryValues.length + 1, }; const response = await addCategoryValue(newValue); if (response.success && response.data) { setCategoryValues([...categoryValues, response.data]); setNewValueCode(""); setNewValueLabel(""); setIsAddingValue(false); toast({ title: "성공", description: "카테고리 값이 추가되었습니다", }); } } catch (error) { toast({ title: "오류", description: "카테고리 값 추가에 실패했습니다", variant: "destructive", }); } }; const renderCategoryValues = (values: CategoryValue[], depth: number = 0) => { return values.map((value) => (
{depth > 0 && } {value.valueCode} {value.valueLabel}
{value.children && value.children.length > 0 && renderCategoryValues(value.children, depth + 1)}
)); }; const selectedCategoryConfig = categories.find( (c) => c.categoryId === selectedCategory ); return (
{/* 카테고리 선택 */}
{selectedCategoryConfig?.description && (

{selectedCategoryConfig.description}

)}
{/* 다중 선택 허용 */} {selectedCategory && (
onUpdate({ isMultiSelect: checked as boolean }) } />
)} {/* 카테고리 값 목록 */} {selectedCategory && categoryValues.length > 0 && (
{renderCategoryValues(categoryValues)}
)} {/* 새 값 추가 */} {selectedCategory && selectedCategoryConfig?.allowCustomValues && (
{!isAddingValue ? ( ) : (
setNewValueCode(e.target.value)} className="h-8 text-xs" /> setNewValueLabel(e.target.value)} className="h-8 text-xs" />
)}
)} {/* 사용 가능 옵션 표시 */} {selectedCategoryConfig && (
{selectedCategoryConfig.allowCustomValues ? "사용자 정의 허용" : "고정 값"} 최대 깊이: {selectedCategoryConfig.maxDepth}단계
)}
); }; ``` ### 4.5 웹타입 설정 통합 ```typescript // frontend/components/screen/panels/DynamicComponentConfigPanel.tsx에 추가 import { CategoryTypeConfigPanel } from "./webtype-configs/CategoryTypeConfigPanel"; // getComponentConfigPanel 함수에 추가 case "category": return ( { const newConfig = { ...(component.webTypeConfig || {}), ...updates, }; onUpdateComponent({ webTypeConfig: newConfig }); }} menuObjid={currentMenuObjid} // 현재 화면의 메뉴 OBJID /> ); ``` --- ## 5. 화면에서 카테고리 렌더링 ### 5.1 RealtimePreview에 카테고리 타입 추가 ```typescript // frontend/components/screen/RealtimePreview.tsx case "category": return ( ); // 계층 구조 렌더링 헬퍼 함수 const renderCategoryOptions = (values: CategoryValue[], depth: number = 0) => { return values.map((value) => ( {" ".repeat(depth)} {value.valueLabel} {value.children && value.children.length > 0 && renderCategoryOptions(value.children, depth + 1)} )); }; ``` --- ## 6. 사용 시나리오 ### 시나리오 1: 프로젝트 관리 화면에서 카테고리 사용 1. **관리자가 카테고리 생성** - 화면관리에서 "프로젝트 관리" 화면 선택 - 카테고리 관리 메뉴로 이동 - "프로젝트 유형" 카테고리 생성 - 값 추가: 개발, 유지보수, 컨설팅 2. **화면에 카테고리 컴포넌트 배치** - 위젯 추가 → 웹타입: "category" 선택 - 카테고리 선택: "프로젝트 유형" - 라벨 설정: "프로젝트 유형" 3. **사용자가 화면에서 선택** - 프로젝트 등록 화면 접속 - "프로젝트 유형" 드롭다운에서 "개발" 선택 - 저장 시 선택된 코드(DEV)가 데이터베이스에 저장됨 ### 시나리오 2: 계층 구조 카테고리 1. **관리자가 3단계 카테고리 생성** - "지역" 카테고리 생성 (maxDepth: 3) - 1단계: 서울특별시, 부산광역시 - 2단계: 강남구, 송파구 (서울 하위) - 3단계: 역삼동, 삼성동 (강남구 하위) 2. **화면에서 계층 선택** - 사용자가 "서울특별시" 선택 → 강남구, 송파구 표시 - 사용자가 "강남구" 선택 → 역삼동, 삼성동 표시 --- ## 7. 마이그레이션 파일 ```sql -- db/migrations/036_create_menu_categories.sql -- 1. 카테고리 마스터 테이블 생성 CREATE TABLE IF NOT EXISTS menu_categories ( category_id VARCHAR(50) PRIMARY KEY, category_name VARCHAR(100) NOT NULL, menu_objid NUMERIC NOT NULL, description TEXT, allow_custom_values BOOLEAN DEFAULT false, max_depth INTEGER DEFAULT 1, is_multi_select BOOLEAN DEFAULT false, company_code VARCHAR(20) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid) REFERENCES menu_info(objid), CONSTRAINT fk_category_company FOREIGN KEY (company_code) REFERENCES company_info(company_code) ); -- 2. 카테고리 값 테이블 생성 CREATE TABLE IF NOT EXISTS category_values ( value_id SERIAL PRIMARY KEY, category_id VARCHAR(50) 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, is_active BOOLEAN DEFAULT true, company_code VARCHAR(20) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), created_by VARCHAR(50), CONSTRAINT fk_value_category FOREIGN KEY (category_id) REFERENCES menu_categories(category_id) ON DELETE CASCADE, CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id) REFERENCES category_values(value_id), CONSTRAINT fk_value_company FOREIGN KEY (company_code) REFERENCES company_info(company_code), CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code) ); -- 3. 인덱스 생성 CREATE INDEX idx_menu_categories_menu ON menu_categories(menu_objid); CREATE INDEX idx_menu_categories_company ON menu_categories(company_code); CREATE INDEX idx_menu_categories_scope ON menu_categories(company_code, menu_objid); CREATE INDEX idx_category_values_category ON category_values(category_id); CREATE INDEX idx_category_values_parent ON category_values(parent_value_id); CREATE INDEX idx_category_values_company ON category_values(company_code); -- 4. 코멘트 추가 COMMENT ON TABLE menu_categories IS '메뉴별 카테고리 마스터'; COMMENT ON TABLE category_values IS '카테고리 값 (계층 구조 지원)'; -- 5. 샘플 데이터 -- (위 2.3 섹션의 샘플 데이터 삽입 쿼리) -- 완료 메시지 SELECT 'Migration 036: Menu Categories created successfully!' AS status; ``` --- ## 8. 구현 체크리스트 ### 데이터베이스 ✅ - [ ] 마이그레이션 파일 작성 (036_create_menu_categories.sql) - [ ] 테이블 생성 및 인덱스 - [ ] 샘플 데이터 삽입 - [ ] 외래키 제약조건 설정 ### 백엔드 ✅ - [ ] 타입 정의 (backend-node/src/types/category.ts) - [ ] 서비스 레이어 (categoryService.ts) - [ ] 컨트롤러 레이어 (categoryController.ts) - [ ] 라우트 설정 (categoryRoutes.ts) - [ ] app.ts에 라우트 등록 ### 프론트엔드 ✅ - [ ] 타입 정의 (frontend/types/category.ts) - [ ] API 클라이언트 (frontend/lib/api/category.ts) - [ ] WebType에 "category" 추가 - [ ] CategoryTypeConfigPanel 컴포넌트 - [ ] DynamicComponentConfigPanel 통합 - [ ] RealtimePreview에 렌더링 로직 추가 ### 테스트 ✅ - [ ] 카테고리 생성/조회/수정/삭제 API 테스트 - [ ] 메뉴별 카테고리 필터링 테스트 - [ ] 계층 구조 렌더링 테스트 - [ ] 멀티테넌시 격리 테스트 --- ## 9. 향후 확장 가능성 ### 9.1 동적 카테고리 검색 - 카테고리 값이 많을 때 Combobox로 변경 - 자동완성 검색 기능 ### 9.2 카테고리 템플릿 - 자주 사용하는 카테고리를 템플릿으로 저장 - 새 메뉴 생성 시 템플릿 적용 ### 9.3 카테고리 분석 - 가장 많이 사용되는 카테고리 값 통계 - 사용되지 않는 카테고리 값 정리 제안 ### 9.4 카테고리 권한 관리 - 특정 사용자만 특정 카테고리 값 선택 가능 - 카테고리 값별 접근 권한 설정 --- ## 10. 요약 **카테고리 시스템**은 채번 규칙과 유사한 구조로 메뉴별로 독립적으로 관리되는 코드 시스템입니다. **핵심 특징**: - ✅ 메뉴별 독립 관리 - ✅ 계층 구조 지원 (최대 3단계) - ✅ 멀티테넌시 격리 - ✅ 동적 추가/수정 가능 - ✅ 다중 선택 옵션 **공통코드와의 차이점**: - 공통코드: 전사 공통 (모든 메뉴에서 동일) - 카테고리: 메뉴별 독립 (각 화면마다 다른 값) 이제 이 계획서를 기반으로 구현을 시작하시겠습니까?