ERP-node/카테고리_시스템_구현_계획서.md

43 KiB

카테고리 시스템 구현 계획서

작성일: 2025-11-04
목적: 메뉴별로 별도 관리되는 카테고리 코드 시스템 구현


1. 개요

1.1 카테고리 시스템이란?

공통코드와 유사하지만 메뉴별로 독립적으로 관리되는 코드 시스템입니다.

주요 특징:

  • 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리
  • 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계)
  • 멀티테넌시: 회사별로 독립적으로 관리
  • 동적 추가/수정: 관리자가 화면에서 직접 카테고리 추가/수정 가능

1.2 사용 예시

프로젝트 관리 화면

  • 프로젝트 유형: 개발, 유지보수, 컨설팅
  • 프로젝트 상태: 계획, 진행중, 완료, 보류
  • 우선순위: 긴급, 높음, 보통, 낮음

계약관리 화면

  • 계약 유형: 판매, 구매, 임대, 용역
  • 계약 상태: 작성중, 검토중, 체결, 종료
  • 결제 방식: 현금, 카드, 계좌이체, 어음

자산관리 화면

  • 자산 분류: IT장비, 비품, 차량, 부동산
  • 자산 상태: 정상, 수리중, 폐기, 분실
  • 위치: 본사, 지점A, 지점B, 창고

2. 데이터베이스 설계

2.1 카테고리 마스터 테이블

-- 메뉴별 카테고리 마스터
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 카테고리 값 테이블

-- 카테고리 값 (계층 구조 지원)
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 샘플 데이터

-- 샘플 카테고리: 프로젝트 유형 (메뉴 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 타입 정의

// 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 서비스 레이어

// 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<CategoryConfig[]> {
    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<CategoryValue[]> {
    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<CategoryConfig> {
    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<CategoryValue> {
    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<CategoryValue>,
    companyCode: string
  ): Promise<CategoryValue> {
    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<void> {
    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 컨트롤러 레이어

// 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 라우트 설정

// 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;
// backend-node/src/app.ts에 추가

import categoryRoutes from "./routes/categoryRoutes";

// 라우트 등록
app.use("/api/categories", categoryRoutes);

4. 프론트엔드 구현

4.1 타입 정의

// 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 클라이언트

// 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<CategoryValue>) {
  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 웹타입 확장

// 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 카테고리 설정 패널

// 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<CategoryConfig[]>([]);
  const [selectedCategory, setSelectedCategory] = useState<string>(config.categoryId || "");
  const [categoryValues, setCategoryValues] = useState<CategoryValue[]>([]);
  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) => (
      <div key={value.valueId} className={`ml-${depth * 4}`}>
        <div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md">
          {depth > 0 && <ChevronRight className="h-3 w-3" />}
          <Badge variant="outline" className="text-xs">
            {value.valueCode}
          </Badge>
          <span className="text-sm">{value.valueLabel}</span>
        </div>
        {value.children && value.children.length > 0 && renderCategoryValues(value.children, depth + 1)}
      </div>
    ));
  };

  const selectedCategoryConfig = categories.find((c) => c.categoryId === selectedCategory);

  return (
    <div className="space-y-4">
      {/* 카테고리 선택 */}
      <div>
        <Label className="text-xs font-medium sm:text-sm">카테고리 선택</Label>
        <Select value={selectedCategory} onValueChange={handleCategoryChange}>
          <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
            <SelectValue placeholder="카테고리를 선택하세요" />
          </SelectTrigger>
          <SelectContent>
            {categories.map((cat) => (
              <SelectItem key={cat.categoryId} value={cat.categoryId} className="text-xs sm:text-sm">
                {cat.categoryName}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        {selectedCategoryConfig?.description && (
          <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
            {selectedCategoryConfig.description}
          </p>
        )}
      </div>

      {/* 다중 선택 허용 */}
      {selectedCategory && (
        <div className="flex items-center gap-2">
          <Checkbox
            id="multiSelect"
            checked={config.isMultiSelect || false}
            onCheckedChange={(checked) => onUpdate({ isMultiSelect: checked as boolean })}
          />
          <Label htmlFor="multiSelect" className="text-xs sm:text-sm">
            다중 선택 허용
          </Label>
        </div>
      )}

      {/* 카테고리 값 목록 */}
      {selectedCategory && categoryValues.length > 0 && (
        <div>
          <Label className="text-xs font-medium sm:text-sm">카테고리  목록</Label>
          <div className="mt-2 max-h-64 overflow-y-auto rounded-md border p-2">
            {renderCategoryValues(categoryValues)}
          </div>
        </div>
      )}

      {/* 새 값 추가 */}
      {selectedCategory && selectedCategoryConfig?.allowCustomValues && (
        <div>
          {!isAddingValue ? (
            <Button variant="outline" size="sm" onClick={() => setIsAddingValue(true)} className="w-full">
              <Plus className="mr-2 h-4 w-4" />
                추가
            </Button>
          ) : (
            <div className="space-y-2 rounded-md border p-3">
              <div className="flex items-center justify-between">
                <Label className="text-xs font-medium sm:text-sm"> 카테고리 </Label>
                <Button variant="ghost" size="icon" onClick={() => setIsAddingValue(false)} className="h-6 w-6">
                  <X className="h-3 w-3" />
                </Button>
              </div>
              <Input
                placeholder="코드 (예: NEW_CODE)"
                value={newValueCode}
                onChange={(e) => setNewValueCode(e.target.value)}
                className="h-8 text-xs"
              />
              <Input
                placeholder="라벨 (예: 새 항목)"
                value={newValueLabel}
                onChange={(e) => setNewValueLabel(e.target.value)}
                className="h-8 text-xs"
              />
              <Button onClick={handleAddValue} size="sm" className="w-full">
                추가
              </Button>
            </div>
          )}
        </div>
      )}

      {/* 사용 가능 옵션 표시 */}
      {selectedCategoryConfig && (
        <div className="rounded-md bg-muted p-3 text-xs">
          <div className="flex gap-2">
            <Badge variant={selectedCategoryConfig.allowCustomValues ? "default" : "secondary"} className="text-[10px]">
              {selectedCategoryConfig.allowCustomValues ? "사용자 정의 허용" : "고정 값"}
            </Badge>
            <Badge variant="outline" className="text-[10px]">
              최대 깊이: {selectedCategoryConfig.maxDepth}단계
            </Badge>
          </div>
        </div>
      )}
    </div>
  );
};

4.5 웹타입 설정 통합

// frontend/components/screen/panels/DynamicComponentConfigPanel.tsx에 추가

import { CategoryTypeConfigPanel } from "./webtype-configs/CategoryTypeConfigPanel";

// getComponentConfigPanel 함수에 추가
case "category":
  return (
    <CategoryTypeConfigPanel
      config={component.webTypeConfig || {}}
      onUpdate={(updates) => {
        const newConfig = {
          ...(component.webTypeConfig || {}),
          ...updates,
        };
        onUpdateComponent({ webTypeConfig: newConfig });
      }}
      menuObjid={currentMenuObjid} // 현재 화면의 메뉴 OBJID
    />
  );

5. 화면에서 카테고리 렌더링

5.1 RealtimePreview에 카테고리 타입 추가

// frontend/components/screen/RealtimePreview.tsx

case "category":
  return (
    <Select
      value={component.webTypeConfig?.selectedValue || ""}
      onValueChange={(value) => {
        // 값 선택 시 처리
        console.log("선택된 카테고리 값:", value);
      }}
    >
      <SelectTrigger
        className="w-full"
        style={{
          fontSize: component.style?.fontSize || "14px",
          height: component.height ? `${component.height}px` : undefined,
        }}
      >
        <SelectValue placeholder={component.placeholder || "선택하세요"} />
      </SelectTrigger>
      <SelectContent>
        {renderCategoryOptions(component.webTypeConfig?.values || [])}
      </SelectContent>
    </Select>
  );

// 계층 구조 렌더링 헬퍼 함수
const renderCategoryOptions = (values: CategoryValue[], depth: number = 0) => {
  return values.map((value) => (
    <React.Fragment key={value.valueId}>
      <SelectItem value={value.valueCode} className="text-xs sm:text-sm">
        {"  ".repeat(depth)}
        {value.valueLabel}
      </SelectItem>
      {value.children && value.children.length > 0 && renderCategoryOptions(value.children, depth + 1)}
    </React.Fragment>
  ));
};

6. 사용 시나리오

시나리오 1: 프로젝트 관리 화면에서 카테고리 사용

  1. 관리자가 카테고리 생성

    • 화면관리에서 "프로젝트 관리" 화면 선택
    • 카테고리 관리 메뉴로 이동
    • "프로젝트 유형" 카테고리 생성
    • 값 추가: 개발, 유지보수, 컨설팅
  2. 화면에 카테고리 컴포넌트 배치

    • 위젯 추가 → 웹타입: "category" 선택
    • 카테고리 선택: "프로젝트 유형"
    • 라벨 설정: "프로젝트 유형"
  3. 사용자가 화면에서 선택

    • 프로젝트 등록 화면 접속
    • "프로젝트 유형" 드롭다운에서 "개발" 선택
    • 저장 시 선택된 코드(DEV)가 데이터베이스에 저장됨

시나리오 2: 계층 구조 카테고리

  1. 관리자가 3단계 카테고리 생성

    • "지역" 카테고리 생성 (maxDepth: 3)
    • 1단계: 서울특별시, 부산광역시
    • 2단계: 강남구, 송파구 (서울 하위)
    • 3단계: 역삼동, 삼성동 (강남구 하위)
  2. 화면에서 계층 선택

    • 사용자가 "서울특별시" 선택 → 강남구, 송파구 표시
    • 사용자가 "강남구" 선택 → 역삼동, 삼성동 표시

7. 마이그레이션 파일

-- 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단계)
  • 멀티테넌시 격리
  • 동적 추가/수정 가능
  • 다중 선택 옵션

공통코드와의 차이점:

  • 공통코드: 전사 공통 (모든 메뉴에서 동일)
  • 카테고리: 메뉴별 독립 (각 화면마다 다른 값)

이제 이 계획서를 기반으로 구현을 시작하시겠습니까?