Files
vexplor/카테고리_관리_컴포넌트_구현_계획서.md
2025-11-05 15:23:57 +09:00

69 KiB

카테고리 관리 컴포넌트 구현 계획서

작성일: 2025-11-04
목적: 테이블의 카테고리 타입 컬럼별로 값을 관리하는 좌우 분할 패널 컴포넌트


1. 개요

1.1 카테고리 관리 컴포넌트란?

테이블의 카테고리 타입 컬럼에 대한 값(코드)를 관리하는 전용 컴포넌트입니다.

1.2 UI 구조

┌─────────────────────────────────────────────────────────────┐
│                  카테고리 관리                                 │
├──────────────────┬──────────────────────────────────────────┤
│  카테고리 목록     │         선택된 카테고리 값 관리              │
│  (좌측 패널)      │         (우측 패널)                       │
├──────────────────┼──────────────────────────────────────────┤
│                  │                                          │
│ ☑ 프로젝트 유형   │  프로젝트 유형 값 관리                     │
│   프로젝트 상태   │  ┌────────────────────────────────┐      │
│   우선순위       │  │ [검색창]              [+ 새 값] │      │
│   담당 부서      │  └────────────────────────────────┘      │
│                  │                                          │
│                  │  ┌─ 값 목록 ─────────────────────┐       │
│                  │  │ □ DEV      개발          [편집][삭제] │  │
│                  │  │ □ MAINT    유지보수       [편집][삭제] │  │
│                  │  │ □ CONSULT  컨설팅         [편집][삭제] │  │
│                  │  │ □ RESEARCH 연구개발       [편집][삭제] │  │
│                  │  └──────────────────────────────┘       │
│                  │                                          │
│                  │  선택된 항목: 2개                          │
│                  │  [일괄 활성화] [일괄 비활성화] [일괄 삭제]   │
│                  │                                          │
└──────────────────┴──────────────────────────────────────────┘

1.3 주요 기능

좌측 패널 (카테고리 목록):

  • 현재 테이블의 카테고리 타입 컬럼들을 라벨명으로 표시
  • 선택된 카테고리 강조 표시
  • 각 카테고리별 값 개수 뱃지 표시

우측 패널 (카테고리 값 관리):

  • 선택된 카테고리의 값 목록 표시
  • 값 추가/편집/삭제
  • 값 검색 및 필터링
  • 값 정렬 순서 변경 (드래그앤드롭)
  • 일괄 선택 및 일괄 작업
  • 활성화/비활성화 상태 관리

2. 데이터 구조

2.1 카테고리 타입이란?

테이블 설계 시 컬럼의 web_typecategory로 지정하면, 해당 컬럼은 미리 정의된 코드 값만 입력 가능합니다.

예시: projects 테이블

컬럼명 웹타입 설명
project_type category 프로젝트 유형 (개발, 유지보수, 컨설팅)
project_status category 프로젝트 상태 (계획, 진행중, 완료)
priority category 우선순위 (긴급, 높음, 보통, 낮음)

2.2 데이터베이스 스키마

테이블 컬럼 정의 (기존)

-- 테이블 컬럼 정의
SELECT 
  column_name,
  column_label,
  web_type
FROM table_columns
WHERE table_name = 'projects'
  AND web_type = 'category';

카테고리 값 저장 테이블 (신규)

-- 테이블 컬럼별 카테고리 값
CREATE TABLE IF NOT EXISTS 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,         -- 코드 (예: DEV, MAINT)
  value_label VARCHAR(100) NOT NULL,       -- 라벨 (예: 개발, 유지보수)
  value_order INTEGER DEFAULT 0,           -- 정렬 순서
  
  -- 계층 구조 (선택)
  parent_value_id INTEGER,                 -- 상위 값 ID
  depth INTEGER DEFAULT 1,                 -- 계층 깊이
  
  -- 추가 정보
  description TEXT,                        -- 설명
  color VARCHAR(20),                       -- 색상 (UI 표시용)
  icon VARCHAR(50),                        -- 아이콘 (선택)
  is_active BOOLEAN DEFAULT true,          -- 활성화 여부
  is_default BOOLEAN DEFAULT false,        -- 기본값 여부
  
  -- 멀티테넌시
  company_code VARCHAR(20) NOT NULL,
  
  -- 메타 정보
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  created_by VARCHAR(50),
  updated_by VARCHAR(50),
  
  CONSTRAINT fk_category_value_company FOREIGN KEY (company_code) 
    REFERENCES company_info(company_code),
  CONSTRAINT unique_table_column_code UNIQUE (table_name, column_name, value_code, company_code)
);

-- 인덱스
CREATE INDEX idx_category_values_table ON table_column_category_values(table_name, column_name);
CREATE INDEX idx_category_values_company ON table_column_category_values(company_code);
CREATE INDEX idx_category_values_parent ON table_column_category_values(parent_value_id);
CREATE INDEX idx_category_values_active ON table_column_category_values(is_active);

-- 코멘트
COMMENT ON TABLE table_column_category_values IS '테이블 컬럼별 카테고리 값';
COMMENT ON COLUMN table_column_category_values.value_code IS '코드 (DB 저장값)';
COMMENT ON COLUMN table_column_category_values.value_label IS '라벨 (UI 표시명)';
COMMENT ON COLUMN table_column_category_values.value_order IS '정렬 순서 (낮을수록 앞)';
COMMENT ON COLUMN table_column_category_values.is_default IS '기본값 여부 (자동 선택)';

샘플 데이터

-- 프로젝트 유형 카테고리 값
INSERT INTO table_column_category_values 
  (table_name, column_name, value_code, value_label, value_order, description, color, company_code, created_by)
VALUES
  ('projects', 'project_type', 'DEV', '개발', 1, '신규 시스템 개발 프로젝트', '#3b82f6', 'COMPANY_A', 'admin'),
  ('projects', 'project_type', 'MAINT', '유지보수', 2, '기존 시스템 유지보수', '#10b981', 'COMPANY_A', 'admin'),
  ('projects', 'project_type', 'CONSULT', '컨설팅', 3, '컨설팅 프로젝트', '#8b5cf6', 'COMPANY_A', 'admin'),
  ('projects', 'project_type', 'RESEARCH', '연구개발', 4, 'R&D 프로젝트', '#f59e0b', 'COMPANY_A', 'admin');

-- 프로젝트 상태 카테고리 값
INSERT INTO table_column_category_values 
  (table_name, column_name, value_code, value_label, value_order, color, is_default, company_code, created_by)
VALUES
  ('projects', 'project_status', 'PLAN', '계획', 1, '#6b7280', true, 'COMPANY_A', 'admin'),
  ('projects', 'project_status', 'PROGRESS', '진행중', 2, '#3b82f6', false, 'COMPANY_A', 'admin'),
  ('projects', 'project_status', 'COMPLETE', '완료', 3, '#10b981', false, 'COMPANY_A', 'admin'),
  ('projects', 'project_status', 'HOLD', '보류', 4, '#ef4444', false, 'COMPANY_A', 'admin');

-- 우선순위 카테고리 값
INSERT INTO table_column_category_values 
  (table_name, column_name, value_code, value_label, value_order, color, company_code, created_by)
VALUES
  ('projects', 'priority', 'URGENT', '긴급', 1, '#ef4444', 'COMPANY_A', 'admin'),
  ('projects', 'priority', 'HIGH', '높음', 2, '#f59e0b', 'COMPANY_A', 'admin'),
  ('projects', 'priority', 'MEDIUM', '보통', 3, '#3b82f6', 'COMPANY_A', 'admin'),
  ('projects', 'priority', 'LOW', '낮음', 4, '#6b7280', 'COMPANY_A', 'admin');

3. 백엔드 구현

3.1 타입 정의

// backend-node/src/types/tableCategoryValue.ts

export interface TableCategoryValue {
  valueId?: number;
  tableName: string;
  columnName: string;
  
  // 값 정보
  valueCode: string;
  valueLabel: string;
  valueOrder?: number;
  
  // 계층 구조
  parentValueId?: number;
  depth?: number;
  
  // 추가 정보
  description?: string;
  color?: string;
  icon?: string;
  isActive?: boolean;
  isDefault?: boolean;
  
  // 하위 항목 (조회 시)
  children?: TableCategoryValue[];
  
  // 멀티테넌시
  companyCode?: string;
  
  // 메타
  createdAt?: string;
  updatedAt?: string;
  createdBy?: string;
  updatedBy?: string;
}

export interface CategoryColumn {
  tableName: string;
  columnName: string;
  columnLabel: string;
  valueCount?: number; // 값 개수
}

3.2 서비스 레이어

// backend-node/src/services/tableCategoryValueService.ts

import { getPool } from "../config/database";
import logger from "../config/logger";
import { TableCategoryValue, CategoryColumn } from "../types/tableCategoryValue";

class TableCategoryValueService {
  /**
   * 테이블의 카테고리 타입 컬럼 목록 조회
   */
  async getCategoryColumns(
    tableName: string,
    companyCode: string
  ): Promise<CategoryColumn[]> {
    try {
      logger.info("카테고리 컬럼 목록 조회", { tableName, companyCode });

      const pool = getPool();
      const query = `
        SELECT 
          tc.table_name AS "tableName",
          tc.column_name AS "columnName",
          tc.column_label AS "columnLabel",
          COUNT(cv.value_id) AS "valueCount"
        FROM table_columns tc
        LEFT JOIN table_column_category_values cv 
          ON tc.table_name = cv.table_name 
          AND tc.column_name = cv.column_name
          AND cv.is_active = true
          AND (cv.company_code = $2 OR cv.company_code = '*')
        WHERE tc.table_name = $1
          AND tc.web_type = 'category'
          AND (tc.company_code = $2 OR tc.company_code = '*')
        GROUP BY tc.table_name, tc.column_name, tc.column_label
        ORDER BY tc.column_order, tc.column_label
      `;

      const result = await pool.query(query, [tableName, companyCode]);

      logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
        tableName,
        companyCode,
      });

      return result.rows;
    } catch (error: any) {
      logger.error(`카테고리 컬럼 조회 실패: ${error.message}`);
      throw error;
    }
  }

  /**
   * 특정 컬럼의 카테고리 값 목록 조회
   */
  async getCategoryValues(
    tableName: string,
    columnName: string,
    companyCode: string,
    includeInactive: boolean = false
  ): Promise<TableCategoryValue[]> {
    try {
      logger.info("카테고리 값 목록 조회", {
        tableName,
        columnName,
        companyCode,
        includeInactive,
      });

      const pool = getPool();
      let query = `
        SELECT 
          value_id AS "valueId",
          table_name AS "tableName",
          column_name AS "columnName",
          value_code AS "valueCode",
          value_label AS "valueLabel",
          value_order AS "valueOrder",
          parent_value_id AS "parentValueId",
          depth,
          description,
          color,
          icon,
          is_active AS "isActive",
          is_default AS "isDefault",
          company_code AS "companyCode",
          created_at AS "createdAt",
          updated_at AS "updatedAt",
          created_by AS "createdBy",
          updated_by AS "updatedBy"
        FROM table_column_category_values
        WHERE table_name = $1
          AND column_name = $2
          AND (company_code = $3 OR company_code = '*')
      `;

      const params: any[] = [tableName, columnName, companyCode];

      if (!includeInactive) {
        query += ` AND is_active = true`;
      }

      query += ` ORDER BY value_order, value_label`;

      const result = await pool.query(query, params);

      // 계층 구조로 변환
      const values = this.buildHierarchy(result.rows);

      logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`, {
        tableName,
        columnName,
      });

      return values;
    } catch (error: any) {
      logger.error(`카테고리 값 조회 실패: ${error.message}`);
      throw error;
    }
  }

  /**
   * 카테고리 값 추가
   */
  async addCategoryValue(
    value: TableCategoryValue,
    companyCode: string,
    userId: string
  ): Promise<TableCategoryValue> {
    const pool = getPool();

    try {
      // 중복 코드 체크
      const duplicateQuery = `
        SELECT value_id 
        FROM table_column_category_values
        WHERE table_name = $1
          AND column_name = $2
          AND value_code = $3
          AND (company_code = $4 OR company_code = '*')
      `;

      const duplicateResult = await pool.query(duplicateQuery, [
        value.tableName,
        value.columnName,
        value.valueCode,
        companyCode,
      ]);

      if (duplicateResult.rows.length > 0) {
        throw new Error("이미 존재하는 코드입니다");
      }

      const insertQuery = `
        INSERT INTO table_column_category_values (
          table_name, column_name, value_code, value_label, value_order,
          parent_value_id, depth, description, color, icon,
          is_active, is_default, company_code, created_by
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
        RETURNING 
          value_id AS "valueId",
          table_name AS "tableName",
          column_name AS "columnName",
          value_code AS "valueCode",
          value_label AS "valueLabel",
          value_order AS "valueOrder",
          parent_value_id AS "parentValueId",
          depth,
          description,
          color,
          icon,
          is_active AS "isActive",
          is_default AS "isDefault",
          company_code AS "companyCode",
          created_at AS "createdAt",
          created_by AS "createdBy"
      `;

      const result = await pool.query(insertQuery, [
        value.tableName,
        value.columnName,
        value.valueCode,
        value.valueLabel,
        value.valueOrder || 0,
        value.parentValueId || null,
        value.depth || 1,
        value.description || null,
        value.color || null,
        value.icon || null,
        value.isActive !== false,
        value.isDefault || false,
        companyCode,
        userId,
      ]);

      logger.info("카테고리 값 추가 완료", {
        valueId: result.rows[0].valueId,
        tableName: value.tableName,
        columnName: value.columnName,
      });

      return result.rows[0];
    } catch (error: any) {
      logger.error(`카테고리 값 추가 실패: ${error.message}`);
      throw error;
    }
  }

  /**
   * 카테고리 값 수정
   */
  async updateCategoryValue(
    valueId: number,
    updates: Partial<TableCategoryValue>,
    companyCode: string,
    userId: string
  ): Promise<TableCategoryValue> {
    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.color !== undefined) {
        setClauses.push(`color = $${paramIndex++}`);
        values.push(updates.color);
      }

      if (updates.icon !== undefined) {
        setClauses.push(`icon = $${paramIndex++}`);
        values.push(updates.icon);
      }

      if (updates.isActive !== undefined) {
        setClauses.push(`is_active = $${paramIndex++}`);
        values.push(updates.isActive);
      }

      if (updates.isDefault !== undefined) {
        setClauses.push(`is_default = $${paramIndex++}`);
        values.push(updates.isDefault);
      }

      setClauses.push(`updated_at = NOW()`);
      setClauses.push(`updated_by = $${paramIndex++}`);
      values.push(userId);

      values.push(valueId, companyCode);

      const updateQuery = `
        UPDATE table_column_category_values
        SET ${setClauses.join(", ")}
        WHERE value_id = $${paramIndex++}
          AND (company_code = $${paramIndex++} OR company_code = '*')
        RETURNING 
          value_id AS "valueId",
          table_name AS "tableName",
          column_name AS "columnName",
          value_code AS "valueCode",
          value_label AS "valueLabel",
          value_order AS "valueOrder",
          description,
          color,
          icon,
          is_active AS "isActive",
          is_default AS "isDefault",
          updated_at AS "updatedAt",
          updated_by AS "updatedBy"
      `;

      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,
    userId: string
  ): Promise<void> {
    const pool = getPool();

    try {
      // 하위 값 체크
      const checkQuery = `
        SELECT COUNT(*) as count
        FROM table_column_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 table_column_category_values
        SET is_active = false, updated_at = NOW(), updated_by = $3
        WHERE value_id = $1
          AND (company_code = $2 OR company_code = '*')
      `;

      await pool.query(deleteQuery, [valueId, companyCode, userId]);

      logger.info("카테고리 값 삭제(비활성화) 완료", {
        valueId,
        companyCode,
      });
    } catch (error: any) {
      logger.error(`카테고리 값 삭제 실패: ${error.message}`);
      throw error;
    }
  }

  /**
   * 카테고리 값 일괄 삭제
   */
  async bulkDeleteCategoryValues(
    valueIds: number[],
    companyCode: string,
    userId: string
  ): Promise<void> {
    const pool = getPool();

    try {
      const deleteQuery = `
        UPDATE table_column_category_values
        SET is_active = false, updated_at = NOW(), updated_by = $3
        WHERE value_id = ANY($1::int[])
          AND (company_code = $2 OR company_code = '*')
      `;

      await pool.query(deleteQuery, [valueIds, companyCode, userId]);

      logger.info("카테고리 값 일괄 삭제 완료", {
        count: valueIds.length,
        companyCode,
      });
    } catch (error: any) {
      logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
      throw error;
    }
  }

  /**
   * 카테고리 값 순서 변경
   */
  async reorderCategoryValues(
    orderedValueIds: number[],
    companyCode: string
  ): Promise<void> {
    const pool = getPool();
    const client = await pool.connect();

    try {
      await client.query("BEGIN");

      for (let i = 0; i < orderedValueIds.length; i++) {
        const updateQuery = `
          UPDATE table_column_category_values
          SET value_order = $1, updated_at = NOW()
          WHERE value_id = $2
            AND (company_code = $3 OR company_code = '*')
        `;

        await client.query(updateQuery, [i + 1, orderedValueIds[i], companyCode]);
      }

      await client.query("COMMIT");

      logger.info("카테고리 값 순서 변경 완료", {
        count: orderedValueIds.length,
        companyCode,
      });
    } catch (error: any) {
      await client.query("ROLLBACK");
      logger.error(`카테고리 값 순서 변경 실패: ${error.message}`);
      throw error;
    } finally {
      client.release();
    }
  }

  /**
   * 계층 구조 변환 헬퍼
   */
  private buildHierarchy(
    values: TableCategoryValue[],
    parentId: number | null = null
  ): TableCategoryValue[] {
    return values
      .filter((v) => v.parentValueId === parentId)
      .map((v) => ({
        ...v,
        children: this.buildHierarchy(values, v.valueId!),
      }));
  }
}

export default new TableCategoryValueService();

3.3 컨트롤러 레이어

// backend-node/src/controllers/tableCategoryValueController.ts

import { Request, Response } from "express";
import tableCategoryValueService from "../services/tableCategoryValueService";
import logger from "../config/logger";

/**
 * 테이블의 카테고리 컬럼 목록 조회
 */
export const getCategoryColumns = async (req: Request, res: Response) => {
  try {
    const companyCode = req.user!.companyCode;
    const { tableName } = req.params;

    const columns = await tableCategoryValueService.getCategoryColumns(
      tableName,
      companyCode
    );

    return res.json({
      success: true,
      data: columns,
    });
  } 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 { tableName, columnName } = req.params;
    const includeInactive = req.query.includeInactive === "true";

    const values = await tableCategoryValueService.getCategoryValues(
      tableName,
      columnName,
      companyCode,
      includeInactive
    );

    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 addCategoryValue = async (req: Request, res: Response) => {
  try {
    const companyCode = req.user!.companyCode;
    const userId = req.user!.userId;
    const value = req.body;

    const newValue = await tableCategoryValueService.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.message || "카테고리 값 추가 중 오류가 발생했습니다",
      error: error.message,
    });
  }
};

/**
 * 카테고리 값 수정
 */
export const updateCategoryValue = async (req: Request, res: Response) => {
  try {
    const companyCode = req.user!.companyCode;
    const userId = req.user!.userId;
    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 tableCategoryValueService.updateCategoryValue(
      valueId,
      updates,
      companyCode,
      userId
    );

    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 userId = req.user!.userId;
    const valueId = parseInt(req.params.valueId);

    if (isNaN(valueId)) {
      return res.status(400).json({
        success: false,
        message: "유효하지 않은 값 ID입니다",
      });
    }

    await tableCategoryValueService.deleteCategoryValue(
      valueId,
      companyCode,
      userId
    );

    return res.json({
      success: true,
      message: "카테고리 값이 삭제되었습니다",
    });
  } catch (error: any) {
    logger.error(`카테고리 값 삭제 실패: ${error.message}`);
    return res.status(500).json({
      success: false,
      message: error.message || "카테고리 값 삭제 중 오류가 발생했습니다",
      error: error.message,
    });
  }
};

/**
 * 카테고리 값 일괄 삭제
 */
export const bulkDeleteCategoryValues = async (req: Request, res: Response) => {
  try {
    const companyCode = req.user!.companyCode;
    const userId = req.user!.userId;
    const { valueIds } = req.body;

    if (!Array.isArray(valueIds) || valueIds.length === 0) {
      return res.status(400).json({
        success: false,
        message: "삭제할 값 ID 목록이 필요합니다",
      });
    }

    await tableCategoryValueService.bulkDeleteCategoryValues(
      valueIds,
      companyCode,
      userId
    );

    return res.json({
      success: true,
      message: `${valueIds.length}개의 카테고리 값이 삭제되었습니다`,
    });
  } catch (error: any) {
    logger.error(`카테고리 값 일괄 삭제 실패: ${error.message}`);
    return res.status(500).json({
      success: false,
      message: "카테고리 값 일괄 삭제 중 오류가 발생했습니다",
      error: error.message,
    });
  }
};

/**
 * 카테고리 값 순서 변경
 */
export const reorderCategoryValues = async (req: Request, res: Response) => {
  try {
    const companyCode = req.user!.companyCode;
    const { orderedValueIds } = req.body;

    if (!Array.isArray(orderedValueIds) || orderedValueIds.length === 0) {
      return res.status(400).json({
        success: false,
        message: "순서 정보가 필요합니다",
      });
    }

    await tableCategoryValueService.reorderCategoryValues(
      orderedValueIds,
      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/tableCategoryValueRoutes.ts

import { Router } from "express";
import * as tableCategoryValueController from "../controllers/tableCategoryValueController";
import { authenticate } from "../middleware/authMiddleware";

const router = Router();

// 모든 라우트에 인증 미들웨어 적용
router.use(authenticate);

// 테이블의 카테고리 컬럼 목록 조회
router.get(
  "/:tableName/columns",
  tableCategoryValueController.getCategoryColumns
);

// 카테고리 값 목록 조회
router.get(
  "/:tableName/:columnName/values",
  tableCategoryValueController.getCategoryValues
);

// 카테고리 값 추가
router.post("/values", tableCategoryValueController.addCategoryValue);

// 카테고리 값 수정
router.put("/values/:valueId", tableCategoryValueController.updateCategoryValue);

// 카테고리 값 삭제
router.delete(
  "/values/:valueId",
  tableCategoryValueController.deleteCategoryValue
);

// 카테고리 값 일괄 삭제
router.post(
  "/values/bulk-delete",
  tableCategoryValueController.bulkDeleteCategoryValues
);

// 카테고리 값 순서 변경
router.post(
  "/values/reorder",
  tableCategoryValueController.reorderCategoryValues
);

export default router;
// backend-node/src/app.ts에 추가

import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes";

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

4. 프론트엔드 구현

4.1 타입 정의

// frontend/types/tableCategoryValue.ts

export interface TableCategoryValue {
  valueId?: number;
  tableName: string;
  columnName: string;
  
  // 값 정보
  valueCode: string;
  valueLabel: string;
  valueOrder?: number;
  
  // 계층 구조
  parentValueId?: number;
  depth?: number;
  
  // 추가 정보
  description?: string;
  color?: string;
  icon?: string;
  isActive?: boolean;
  isDefault?: boolean;
  
  // 하위 항목
  children?: TableCategoryValue[];
  
  // 멀티테넌시
  companyCode?: string;
  
  // 메타
  createdAt?: string;
  updatedAt?: string;
  createdBy?: string;
  updatedBy?: string;
}

export interface CategoryColumn {
  tableName: string;
  columnName: string;
  columnLabel: string;
  valueCount?: number;
}

4.2 API 클라이언트

// frontend/lib/api/tableCategoryValue.ts

import apiClient from "./client";
import { TableCategoryValue, CategoryColumn } from "@/types/tableCategoryValue";

/**
 * 테이블의 카테고리 컬럼 목록 조회
 */
export async function getCategoryColumns(tableName: string) {
  try {
    const response = await apiClient.get<{ success: boolean; data: CategoryColumn[] }>(
      `/api/table-categories/${tableName}/columns`
    );
    return response.data;
  } catch (error: any) {
    console.error("카테고리 컬럼 조회 실패:", error);
    return { success: false, error: error.message };
  }
}

/**
 * 카테고리 값 목록 조회
 */
export async function getCategoryValues(
  tableName: string,
  columnName: string,
  includeInactive: boolean = false
) {
  try {
    const response = await apiClient.get<{ success: boolean; data: TableCategoryValue[] }>(
      `/api/table-categories/${tableName}/${columnName}/values`,
      { params: { includeInactive } }
    );
    return response.data;
  } catch (error: any) {
    console.error("카테고리 값 조회 실패:", error);
    return { success: false, error: error.message };
  }
}

/**
 * 카테고리 값 추가
 */
export async function addCategoryValue(value: TableCategoryValue) {
  try {
    const response = await apiClient.post<{ success: boolean; data: TableCategoryValue }>(
      "/api/table-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<TableCategoryValue>
) {
  try {
    const response = await apiClient.put<{ success: boolean; data: TableCategoryValue }>(
      `/api/table-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/table-categories/values/${valueId}`
    );
    return response.data;
  } catch (error: any) {
    console.error("카테고리 값 삭제 실패:", error);
    return { success: false, error: error.message };
  }
}

/**
 * 카테고리 값 일괄 삭제
 */
export async function bulkDeleteCategoryValues(valueIds: number[]) {
  try {
    const response = await apiClient.post<{ success: boolean; message: string }>(
      "/api/table-categories/values/bulk-delete",
      { valueIds }
    );
    return response.data;
  } catch (error: any) {
    console.error("카테고리 값 일괄 삭제 실패:", error);
    return { success: false, error: error.message };
  }
}

/**
 * 카테고리 값 순서 변경
 */
export async function reorderCategoryValues(orderedValueIds: number[]) {
  try {
    const response = await apiClient.post<{ success: boolean; message: string }>(
      "/api/table-categories/values/reorder",
      { orderedValueIds }
    );
    return response.data;
  } catch (error: any) {
    console.error("카테고리 값 순서 변경 실패:", error);
    return { success: false, error: error.message };
  }
}

4.3 카테고리 관리 메인 컴포넌트

// frontend/components/table-category/TableCategoryManager.tsx

"use client";

import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
import { CategoryColumnList } from "./CategoryColumnList";
import { CategoryValueManager } from "./CategoryValueManager";
import { getCategoryColumns } from "@/lib/api/tableCategoryValue";
import { CategoryColumn } from "@/types/tableCategoryValue";
import { useToast } from "@/hooks/use-toast";

interface TableCategoryManagerProps {
  tableName: string;
}

export const TableCategoryManager: React.FC<TableCategoryManagerProps> = ({
  tableName,
}) => {
  const { toast } = useToast();
  const [columns, setColumns] = useState<CategoryColumn[]>([]);
  const [selectedColumn, setSelectedColumn] = useState<CategoryColumn | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  // 카테고리 컬럼 목록 로드
  useEffect(() => {
    loadCategoryColumns();
  }, [tableName]);

  const loadCategoryColumns = async () => {
    setIsLoading(true);
    try {
      const response = await getCategoryColumns(tableName);
      if (response.success && response.data) {
        setColumns(response.data);
        
        // 첫 번째 컬럼 자동 선택
        if (response.data.length > 0 && !selectedColumn) {
          setSelectedColumn(response.data[0]);
        }
      }
    } catch (error) {
      console.error("카테고리 컬럼 로드 실패:", error);
      toast({
        title: "오류",
        description: "카테고리 컬럼을 불러올 수 없습니다",
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  };

  const handleColumnSelect = (column: CategoryColumn) => {
    setSelectedColumn(column);
  };

  const handleValueCountUpdate = (columnName: string, count: number) => {
    setColumns((prev) =>
      prev.map((col) =>
        col.columnName === columnName ? { ...col, valueCount: count } : col
      )
    );
  };

  return (
    <Card className="h-full">
      <CardHeader>
        <CardTitle className="text-xl font-semibold">
          카테고리 관리 - {tableName}
        </CardTitle>
      </CardHeader>
      <CardContent className="p-0">
        <ResizablePanelGroup direction="horizontal" className="h-[calc(100vh-12rem)]">
          {/* 좌측: 카테고리 컬럼 목록 */}
          <ResizablePanel defaultSize={30} minSize={20}>
            <CategoryColumnList
              columns={columns}
              selectedColumn={selectedColumn}
              onColumnSelect={handleColumnSelect}
              isLoading={isLoading}
            />
          </ResizablePanel>

          <ResizableHandle withHandle />

          {/* 우측: 카테고리 값 관리 */}
          <ResizablePanel defaultSize={70} minSize={50}>
            {selectedColumn ? (
              <CategoryValueManager
                tableName={tableName}
                columnName={selectedColumn.columnName}
                columnLabel={selectedColumn.columnLabel}
                onValueCountChange={(count) =>
                  handleValueCountUpdate(selectedColumn.columnName, count)
                }
              />
            ) : (
              <div className="flex h-full items-center justify-center text-muted-foreground">
                좌측에서 카테고리를 선택하세요
              </div>
            )}
          </ResizablePanel>
        </ResizablePanelGroup>
      </CardContent>
    </Card>
  );
};

4.4 좌측: 카테고리 컬럼 목록

// frontend/components/table-category/CategoryColumnList.tsx

"use client";

import React from "react";
import { Badge } from "@/components/ui/badge";
import { Loader2, Database } from "lucide-react";
import { CategoryColumn } from "@/types/tableCategoryValue";
import { cn } from "@/lib/utils";

interface CategoryColumnListProps {
  columns: CategoryColumn[];
  selectedColumn: CategoryColumn | null;
  onColumnSelect: (column: CategoryColumn) => void;
  isLoading: boolean;
}

export const CategoryColumnList: React.FC<CategoryColumnListProps> = ({
  columns,
  selectedColumn,
  onColumnSelect,
  isLoading,
}) => {
  if (isLoading) {
    return (
      <div className="flex h-full items-center justify-center">
        <Loader2 className="h-6 w-6 animate-spin text-primary" />
      </div>
    );
  }

  if (columns.length === 0) {
    return (
      <div className="flex h-full flex-col items-center justify-center p-6 text-center">
        <Database className="mb-4 h-12 w-12 text-muted-foreground" />
        <p className="text-sm text-muted-foreground">
          카테고리 타입 컬럼이 없습니다
        </p>
      </div>
    );
  }

  return (
    <div className="h-full overflow-y-auto border-r">
      <div className="p-4">
        <h3 className="mb-4 text-sm font-semibold text-muted-foreground">
          카테고리 목록
        </h3>
        <div className="space-y-1">
          {columns.map((column) => (
            <button
              key={column.columnName}
              onClick={() => onColumnSelect(column)}
              className={cn(
                "w-full rounded-md p-3 text-left transition-colors",
                "hover:bg-accent",
                selectedColumn?.columnName === column.columnName
                  ? "bg-primary/10 text-primary"
                  : "bg-card text-card-foreground"
              )}
            >
              <div className="flex items-center justify-between">
                <div className="flex-1">
                  <p className="text-sm font-medium">{column.columnLabel}</p>
                  <p className="text-xs text-muted-foreground">
                    {column.columnName}
                  </p>
                </div>
                <Badge
                  variant={column.valueCount && column.valueCount > 0 ? "default" : "secondary"}
                  className="ml-2 text-xs"
                >
                  {column.valueCount || 0}
                </Badge>
              </div>
            </button>
          ))}
        </div>
      </div>
    </div>
  );
};

4.5 우측: 카테고리 값 관리

// frontend/components/table-category/CategoryValueManager.tsx

"use client";

import React, { useState, useEffect } from "react";
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,
  Search,
  Trash2,
  Edit2,
  GripVertical,
  CheckCircle2,
  XCircle,
} from "lucide-react";
import {
  getCategoryValues,
  addCategoryValue,
  updateCategoryValue,
  deleteCategoryValue,
  bulkDeleteCategoryValues,
  reorderCategoryValues,
} from "@/lib/api/tableCategoryValue";
import { TableCategoryValue } from "@/types/tableCategoryValue";
import { useToast } from "@/hooks/use-toast";
import { CategoryValueEditDialog } from "./CategoryValueEditDialog";
import { CategoryValueAddDialog } from "./CategoryValueAddDialog";

interface CategoryValueManagerProps {
  tableName: string;
  columnName: string;
  columnLabel: string;
  onValueCountChange?: (count: number) => void;
}

export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
  tableName,
  columnName,
  columnLabel,
  onValueCountChange,
}) => {
  const { toast } = useToast();
  const [values, setValues] = useState<TableCategoryValue[]>([]);
  const [filteredValues, setFilteredValues] = useState<TableCategoryValue[]>([]);
  const [selectedValueIds, setSelectedValueIds] = useState<number[]>([]);
  const [searchQuery, setSearchQuery] = useState("");
  const [isLoading, setIsLoading] = useState(false);
  const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
  const [editingValue, setEditingValue] = useState<TableCategoryValue | null>(null);

  // 카테고리 값 로드
  useEffect(() => {
    loadCategoryValues();
  }, [tableName, columnName]);

  // 검색 필터링
  useEffect(() => {
    if (searchQuery) {
      const filtered = values.filter(
        (v) =>
          v.valueCode.toLowerCase().includes(searchQuery.toLowerCase()) ||
          v.valueLabel.toLowerCase().includes(searchQuery.toLowerCase())
      );
      setFilteredValues(filtered);
    } else {
      setFilteredValues(values);
    }
  }, [searchQuery, values]);

  const loadCategoryValues = async () => {
    setIsLoading(true);
    try {
      const response = await getCategoryValues(tableName, columnName);
      if (response.success && response.data) {
        setValues(response.data);
        setFilteredValues(response.data);
        onValueCountChange?.(response.data.length);
      }
    } catch (error) {
      console.error("카테고리 값 로드 실패:", error);
      toast({
        title: "오류",
        description: "카테고리 값을 불러올 수 없습니다",
        variant: "destructive",
      });
    } finally {
      setIsLoading(false);
    }
  };

  const handleAddValue = async (newValue: TableCategoryValue) => {
    try {
      const response = await addCategoryValue({
        ...newValue,
        tableName,
        columnName,
      });

      if (response.success && response.data) {
        await loadCategoryValues();
        setIsAddDialogOpen(false);
        toast({
          title: "성공",
          description: "카테고리 값이 추가되었습니다",
        });
      }
    } catch (error) {
      toast({
        title: "오류",
        description: "카테고리 값 추가에 실패했습니다",
        variant: "destructive",
      });
    }
  };

  const handleUpdateValue = async (valueId: number, updates: Partial<TableCategoryValue>) => {
    try {
      const response = await updateCategoryValue(valueId, updates);

      if (response.success) {
        await loadCategoryValues();
        setEditingValue(null);
        toast({
          title: "성공",
          description: "카테고리 값이 수정되었습니다",
        });
      }
    } catch (error) {
      toast({
        title: "오류",
        description: "카테고리 값 수정에 실패했습니다",
        variant: "destructive",
      });
    }
  };

  const handleDeleteValue = async (valueId: number) => {
    if (!confirm("정말로 이 카테고리 값을 삭제하시겠습니까?")) {
      return;
    }

    try {
      const response = await deleteCategoryValue(valueId);

      if (response.success) {
        await loadCategoryValues();
        toast({
          title: "성공",
          description: "카테고리 값이 삭제되었습니다",
        });
      }
    } catch (error) {
      toast({
        title: "오류",
        description: "카테고리 값 삭제에 실패했습니다",
        variant: "destructive",
      });
    }
  };

  const handleBulkDelete = async () => {
    if (selectedValueIds.length === 0) {
      toast({
        title: "알림",
        description: "삭제할 항목을 선택해주세요",
        variant: "destructive",
      });
      return;
    }

    if (!confirm(`선택한 ${selectedValueIds.length}개 항목을 삭제하시겠습니까?`)) {
      return;
    }

    try {
      const response = await bulkDeleteCategoryValues(selectedValueIds);

      if (response.success) {
        setSelectedValueIds([]);
        await loadCategoryValues();
        toast({
          title: "성공",
          description: response.message,
        });
      }
    } catch (error) {
      toast({
        title: "오류",
        description: "일괄 삭제에 실패했습니다",
        variant: "destructive",
      });
    }
  };

  const handleSelectAll = () => {
    if (selectedValueIds.length === filteredValues.length) {
      setSelectedValueIds([]);
    } else {
      setSelectedValueIds(filteredValues.map((v) => v.valueId!));
    }
  };

  const handleSelectValue = (valueId: number) => {
    setSelectedValueIds((prev) =>
      prev.includes(valueId)
        ? prev.filter((id) => id !== valueId)
        : [...prev, valueId]
    );
  };

  return (
    <div className="flex h-full flex-col">
      {/* 헤더 */}
      <div className="border-b p-4">
        <div className="mb-4 flex items-center justify-between">
          <div>
            <h3 className="text-lg font-semibold">{columnLabel}</h3>
            <p className="text-xs text-muted-foreground">
               {filteredValues.length} 항목
            </p>
          </div>
          <Button onClick={() => setIsAddDialogOpen(true)} size="sm">
            <Plus className="mr-2 h-4 w-4" />
              추가
          </Button>
        </div>

        {/* 검색바 */}
        <div className="relative">
          <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
          <Input
            placeholder="코드 또는 라벨 검색..."
            value={searchQuery}
            onChange={(e) => setSearchQuery(e.target.value)}
            className="pl-9"
          />
        </div>
      </div>

      {/* 값 목록 */}
      <div className="flex-1 overflow-y-auto p-4">
        {filteredValues.length === 0 ? (
          <div className="flex h-full items-center justify-center text-center">
            <p className="text-sm text-muted-foreground">
              {searchQuery ? "검색 결과가 없습니다" : "카테고리 값을 추가해주세요"}
            </p>
          </div>
        ) : (
          <div className="space-y-2">
            {filteredValues.map((value) => (
              <div
                key={value.valueId}
                className="flex items-center gap-3 rounded-md border bg-card p-3 transition-colors hover:bg-accent"
              >
                <Checkbox
                  checked={selectedValueIds.includes(value.valueId!)}
                  onCheckedChange={() => handleSelectValue(value.valueId!)}
                />

                <GripVertical className="h-4 w-4 text-muted-foreground" />

                <div className="flex-1">
                  <div className="flex items-center gap-2">
                    <Badge variant="outline" className="text-xs">
                      {value.valueCode}
                    </Badge>
                    <span className="text-sm font-medium">{value.valueLabel}</span>
                    {value.isDefault && (
                      <Badge variant="secondary" className="text-[10px]">
                        기본값
                      </Badge>
                    )}
                    {value.color && (
                      <div
                        className="h-4 w-4 rounded-full border"
                        style={{ backgroundColor: value.color }}
                      />
                    )}
                  </div>
                  {value.description && (
                    <p className="mt-1 text-xs text-muted-foreground">
                      {value.description}
                    </p>
                  )}
                </div>

                <div className="flex items-center gap-2">
                  {value.isActive ? (
                    <CheckCircle2 className="h-4 w-4 text-success" />
                  ) : (
                    <XCircle className="h-4 w-4 text-destructive" />
                  )}

                  <Button
                    variant="ghost"
                    size="icon"
                    onClick={() => setEditingValue(value)}
                    className="h-8 w-8"
                  >
                    <Edit2 className="h-3 w-3" />
                  </Button>

                  <Button
                    variant="ghost"
                    size="icon"
                    onClick={() => handleDeleteValue(value.valueId!)}
                    className="h-8 w-8 text-destructive"
                  >
                    <Trash2 className="h-3 w-3" />
                  </Button>
                </div>
              </div>
            ))}
          </div>
        )}
      </div>

      {/* 푸터: 일괄 작업 */}
      {selectedValueIds.length > 0 && (
        <div className="border-t p-4">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-2">
              <Checkbox checked={selectedValueIds.length === filteredValues.length} onCheckedChange={handleSelectAll} />
              <span className="text-sm text-muted-foreground">
                {selectedValueIds.length} 선택됨
              </span>
            </div>
            <div className="flex gap-2">
              <Button variant="outline" size="sm" onClick={handleBulkDelete}>
                <Trash2 className="mr-2 h-4 w-4" />
                일괄 삭제
              </Button>
            </div>
          </div>
        </div>
      )}

      {/* 추가 다이얼로그 */}
      <CategoryValueAddDialog
        open={isAddDialogOpen}
        onOpenChange={setIsAddDialogOpen}
        onAdd={handleAddValue}
        columnLabel={columnLabel}
      />

      {/* 편집 다이얼로그 */}
      {editingValue && (
        <CategoryValueEditDialog
          open={!!editingValue}
          onOpenChange={(open) => !open && setEditingValue(null)}
          value={editingValue}
          onUpdate={handleUpdateValue}
          columnLabel={columnLabel}
        />
      )}
    </div>
  );
};

4.6 값 추가 다이얼로그

// frontend/components/table-category/CategoryValueAddDialog.tsx

"use client";

import React, { useState } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
  DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";

interface CategoryValueAddDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  onAdd: (value: TableCategoryValue) => void;
  columnLabel: string;
}

export const CategoryValueAddDialog: React.FC<CategoryValueAddDialogProps> = ({
  open,
  onOpenChange,
  onAdd,
  columnLabel,
}) => {
  const [valueCode, setValueCode] = useState("");
  const [valueLabel, setValueLabel] = useState("");
  const [description, setDescription] = useState("");
  const [color, setColor] = useState("#3b82f6");
  const [isDefault, setIsDefault] = useState(false);

  const handleSubmit = () => {
    if (!valueCode || !valueLabel) {
      return;
    }

    onAdd({
      tableName: "",
      columnName: "",
      valueCode: valueCode.toUpperCase(),
      valueLabel,
      description,
      color,
      isDefault,
    });

    // 초기화
    setValueCode("");
    setValueLabel("");
    setDescription("");
    setColor("#3b82f6");
    setIsDefault(false);
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg"> 카테고리  추가</DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            {columnLabel} 새로운 값을 추가합니다
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3 sm:space-y-4">
          <div>
            <Label htmlFor="valueCode" className="text-xs sm:text-sm">
              코드 *
            </Label>
            <Input
              id="valueCode"
              placeholder="예: DEV, URGENT"
              value={valueCode}
              onChange={(e) => setValueCode(e.target.value.toUpperCase())}
              className="h-8 text-xs sm:h-10 sm:text-sm"
            />
            <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
              영문 대문자와 언더스코어만 사용 (DB 저장값)
            </p>
          </div>

          <div>
            <Label htmlFor="valueLabel" className="text-xs sm:text-sm">
              라벨 *
            </Label>
            <Input
              id="valueLabel"
              placeholder="예: 개발, 긴급"
              value={valueLabel}
              onChange={(e) => setValueLabel(e.target.value)}
              className="h-8 text-xs sm:h-10 sm:text-sm"
            />
            <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
              사용자에게 표시될 이름
            </p>
          </div>

          <div>
            <Label htmlFor="description" className="text-xs sm:text-sm">
              설명
            </Label>
            <Textarea
              id="description"
              placeholder="상세 설명 (선택사항)"
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              className="text-xs sm:text-sm"
              rows={3}
            />
          </div>

          <div>
            <Label htmlFor="color" className="text-xs sm:text-sm">
              색상
            </Label>
            <div className="flex gap-2">
              <Input
                id="color"
                type="color"
                value={color}
                onChange={(e) => setColor(e.target.value)}
                className="h-8 w-16 sm:h-10"
              />
              <Input
                type="text"
                value={color}
                onChange={(e) => setColor(e.target.value)}
                className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
              />
            </div>
          </div>

          <div className="flex items-center gap-2">
            <Checkbox
              id="isDefault"
              checked={isDefault}
              onCheckedChange={(checked) => setIsDefault(checked as boolean)}
            />
            <Label htmlFor="isDefault" className="text-xs sm:text-sm">
              기본값으로 설정
            </Label>
          </div>
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            취소
          </Button>
          <Button
            onClick={handleSubmit}
            disabled={!valueCode || !valueLabel}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            추가
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

4.7 값 편집 다이얼로그

// frontend/components/table-category/CategoryValueEditDialog.tsx

"use client";

import React, { useState, useEffect } from "react";
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
  DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { TableCategoryValue } from "@/types/tableCategoryValue";

interface CategoryValueEditDialogProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  value: TableCategoryValue;
  onUpdate: (valueId: number, updates: Partial<TableCategoryValue>) => void;
  columnLabel: string;
}

export const CategoryValueEditDialog: React.FC<CategoryValueEditDialogProps> = ({
  open,
  onOpenChange,
  value,
  onUpdate,
  columnLabel,
}) => {
  const [valueLabel, setValueLabel] = useState(value.valueLabel);
  const [description, setDescription] = useState(value.description || "");
  const [color, setColor] = useState(value.color || "#3b82f6");
  const [isDefault, setIsDefault] = useState(value.isDefault || false);
  const [isActive, setIsActive] = useState(value.isActive !== false);

  useEffect(() => {
    setValueLabel(value.valueLabel);
    setDescription(value.description || "");
    setColor(value.color || "#3b82f6");
    setIsDefault(value.isDefault || false);
    setIsActive(value.isActive !== false);
  }, [value]);

  const handleSubmit = () => {
    if (!valueLabel) {
      return;
    }

    onUpdate(value.valueId!, {
      valueLabel,
      description,
      color,
      isDefault,
      isActive,
    });
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
        <DialogHeader>
          <DialogTitle className="text-base sm:text-lg">카테고리  편집</DialogTitle>
          <DialogDescription className="text-xs sm:text-sm">
            {columnLabel} - {value.valueCode}
          </DialogDescription>
        </DialogHeader>

        <div className="space-y-3 sm:space-y-4">
          <div>
            <Label htmlFor="valueCode" className="text-xs sm:text-sm">
              코드
            </Label>
            <Input
              id="valueCode"
              value={value.valueCode}
              disabled
              className="h-8 text-xs sm:h-10 sm:text-sm"
            />
            <p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
              코드는 변경할  없습니다
            </p>
          </div>

          <div>
            <Label htmlFor="valueLabel" className="text-xs sm:text-sm">
              라벨 *
            </Label>
            <Input
              id="valueLabel"
              value={valueLabel}
              onChange={(e) => setValueLabel(e.target.value)}
              className="h-8 text-xs sm:h-10 sm:text-sm"
            />
          </div>

          <div>
            <Label htmlFor="description" className="text-xs sm:text-sm">
              설명
            </Label>
            <Textarea
              id="description"
              value={description}
              onChange={(e) => setDescription(e.target.value)}
              className="text-xs sm:text-sm"
              rows={3}
            />
          </div>

          <div>
            <Label htmlFor="color" className="text-xs sm:text-sm">
              색상
            </Label>
            <div className="flex gap-2">
              <Input
                id="color"
                type="color"
                value={color}
                onChange={(e) => setColor(e.target.value)}
                className="h-8 w-16 sm:h-10"
              />
              <Input
                type="text"
                value={color}
                onChange={(e) => setColor(e.target.value)}
                className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
              />
            </div>
          </div>

          <div className="flex items-center gap-2">
            <Checkbox
              id="isDefault"
              checked={isDefault}
              onCheckedChange={(checked) => setIsDefault(checked as boolean)}
            />
            <Label htmlFor="isDefault" className="text-xs sm:text-sm">
              기본값으로 설정
            </Label>
          </div>

          <div className="flex items-center gap-2">
            <Checkbox
              id="isActive"
              checked={isActive}
              onCheckedChange={(checked) => setIsActive(checked as boolean)}
            />
            <Label htmlFor="isActive" className="text-xs sm:text-sm">
              활성화
            </Label>
          </div>
        </div>

        <DialogFooter className="gap-2 sm:gap-0">
          <Button
            variant="outline"
            onClick={() => onOpenChange(false)}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            취소
          </Button>
          <Button
            onClick={handleSubmit}
            disabled={!valueLabel}
            className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
          >
            저장
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
};

5. 마이그레이션 파일

-- db/migrations/036_create_table_column_category_values.sql

-- 테이블 컬럼별 카테고리 값 테이블 생성
CREATE TABLE IF NOT EXISTS 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,
  
  -- 메타 정보
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  created_by VARCHAR(50),
  updated_by VARCHAR(50),
  
  CONSTRAINT fk_category_value_company FOREIGN KEY (company_code) 
    REFERENCES company_info(company_code),
  CONSTRAINT unique_table_column_code UNIQUE (table_name, column_name, value_code, company_code)
);

-- 인덱스 생성
CREATE INDEX idx_category_values_table ON table_column_category_values(table_name, column_name);
CREATE INDEX idx_category_values_company ON table_column_category_values(company_code);
CREATE INDEX idx_category_values_parent ON table_column_category_values(parent_value_id);
CREATE INDEX idx_category_values_active ON table_column_category_values(is_active);
CREATE INDEX idx_category_values_order ON table_column_category_values(value_order);

-- 코멘트
COMMENT ON TABLE table_column_category_values IS '테이블 컬럼별 카테고리 값';
COMMENT ON COLUMN table_column_category_values.value_code IS '코드 (DB 저장값, 영문 대문자)';
COMMENT ON COLUMN table_column_category_values.value_label IS '라벨 (UI 표시명)';
COMMENT ON COLUMN table_column_category_values.value_order IS '정렬 순서';
COMMENT ON COLUMN table_column_category_values.color IS 'UI 표시 색상 (Hex)';
COMMENT ON COLUMN table_column_category_values.is_default IS '기본값 여부';

-- 샘플 데이터: 프로젝트 테이블
INSERT INTO table_column_category_values 
  (table_name, column_name, value_code, value_label, value_order, description, color, company_code, created_by)
VALUES
  -- 프로젝트 유형
  ('projects', 'project_type', 'DEV', '개발', 1, '신규 시스템 개발', '#3b82f6', '*', 'system'),
  ('projects', 'project_type', 'MAINT', '유지보수', 2, '기존 시스템 유지보수', '#10b981', '*', 'system'),
  ('projects', 'project_type', 'CONSULT', '컨설팅', 3, '컨설팅 프로젝트', '#8b5cf6', '*', 'system'),
  ('projects', 'project_type', 'RESEARCH', '연구개발', 4, 'R&D 프로젝트', '#f59e0b', '*', 'system'),
  
  -- 프로젝트 상태
  ('projects', 'project_status', 'PLAN', '계획', 1, '프로젝트 계획 단계', '#6b7280', '*', 'system'),
  ('projects', 'project_status', 'PROGRESS', '진행중', 2, '프로젝트 진행 중', '#3b82f6', '*', 'system'),
  ('projects', 'project_status', 'COMPLETE', '완료', 3, '프로젝트 완료', '#10b981', '*', 'system'),
  ('projects', 'project_status', 'HOLD', '보류', 4, '프로젝트 보류', '#ef4444', '*', 'system'),
  
  -- 우선순위
  ('projects', 'priority', 'URGENT', '긴급', 1, '긴급 처리 필요', '#ef4444', '*', 'system'),
  ('projects', 'priority', 'HIGH', '높음', 2, '높은 우선순위', '#f59e0b', '*', 'system'),
  ('projects', 'priority', 'MEDIUM', '보통', 3, '보통 우선순위', '#3b82f6', '*', 'system'),
  ('projects', 'priority', 'LOW', '낮음', 4, '낮은 우선순위', '#6b7280', '*', 'system');

-- 완료 메시지
SELECT 'Migration 036: Table Column Category Values created successfully!' AS status;

6. 메뉴 등록

AppLayout에 메뉴 추가

// frontend/components/layout/AppLayout.tsx

const menuItems = [
  // ... 기존 메뉴들
  {
    id: "table-category-management",
    label: "카테고리 관리",
    icon: Database,
    path: "/table-categories",
    requiresAuth: true,
  },
];

페이지 생성

// frontend/app/table-categories/page.tsx

"use client";

import React, { useState } from "react";
import { TableCategoryManager } from "@/components/table-category/TableCategoryManager";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";

export default function TableCategoriesPage() {
  const [selectedTable, setSelectedTable] = useState("projects");

  // 실제로는 API에서 카테고리 타입 컬럼이 있는 테이블 목록 조회
  const tables = [
    { value: "projects", label: "프로젝트 (projects)" },
    { value: "contracts", label: "계약 (contracts)" },
    { value: "assets", label: "자산 (assets)" },
  ];

  return (
    <div className="container mx-auto p-6 space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">카테고리 관리</h1>
        
        <div className="w-64">
          <Label className="text-xs">테이블 선택</Label>
          <Select value={selectedTable} onValueChange={setSelectedTable}>
            <SelectTrigger>
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {tables.map((table) => (
                <SelectItem key={table.value} value={table.value}>
                  {table.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>
      </div>

      <TableCategoryManager tableName={selectedTable} />
    </div>
  );
}

7. 사용 시나리오

시나리오 1: 새 카테고리 값 추가

  1. 관리자가 "카테고리 관리" 메뉴 접속
  2. 테이블 선택: "프로젝트"
  3. 좌측에서 "프로젝트 유형" 카테고리 선택
  4. "새 값 추가" 버튼 클릭
  5. 코드: "CLOUD", 라벨: "클라우드 마이그레이션" 입력
  6. 색상: 보라색 선택
  7. 추가 버튼 클릭
  8. 즉시 목록에 반영됨

시나리오 2: 카테고리 값 순서 변경

  1. 좌측에서 "우선순위" 카테고리 선택
  2. 값 목록에서 드래그 아이콘으로 순서 변경
  3. "긴급 → 높음 → 보통 → 낮음" 순서로 재배치
  4. 자동 저장됨

시나리오 3: 일괄 삭제

  1. 좌측에서 "프로젝트 상태" 카테고리 선택
  2. 사용하지 않는 값 여러 개 체크박스 선택
  3. "일괄 삭제" 버튼 클릭
  4. 확인 메시지 후 삭제 (비활성화 처리)

8. 구현 체크리스트

데이터베이스

  • 마이그레이션 파일 작성 (036_create_table_column_category_values.sql)
  • 테이블 생성 및 인덱스
  • 샘플 데이터 삽입
  • 외래키 제약조건 설정

백엔드

  • 타입 정의 (tableCategoryValue.ts)
  • 서비스 레이어 (tableCategoryValueService.ts)
  • 컨트롤러 레이어 (tableCategoryValueController.ts)
  • 라우트 설정 (tableCategoryValueRoutes.ts)
  • app.ts에 라우트 등록

프론트엔드

  • 타입 정의 (types/tableCategoryValue.ts)
  • API 클라이언트 (lib/api/tableCategoryValue.ts)
  • 메인 컴포넌트 (TableCategoryManager.tsx)
  • 좌측 패널 (CategoryColumnList.tsx)
  • 우측 패널 (CategoryValueManager.tsx)
  • 추가 다이얼로그 (CategoryValueAddDialog.tsx)
  • 편집 다이얼로그 (CategoryValueEditDialog.tsx)
  • 페이지 생성 (/table-categories/page.tsx)
  • 메뉴 등록

테스트

  • 카테고리 컬럼 목록 조회 API 테스트
  • 카테고리 값 CRUD API 테스트
  • 일괄 작업 API 테스트
  • 순서 변경 기능 테스트
  • 멀티테넌시 격리 테스트

9. 확장 가능성

9.1 드래그앤드롭 순서 변경

  • react-beautiful-dnd 라이브러리 사용
  • 시각적 드래그 피드백

9.2 엑셀 가져오기/내보내기

  • 대량 카테고리 값 일괄 등록
  • 현재 값 목록 엑셀 다운로드

9.3 카테고리 값 사용 현황

  • 각 값이 실제 데이터에 몇 건 사용되는지 통계
  • 사용되지 않는 값 정리 제안

9.4 색상 프리셋

  • 자주 사용하는 색상 팔레트 제공
  • 테마별 색상 조합 추천

10. 요약

카테고리 관리 컴포넌트는 테이블의 카테고리 타입 컬럼에 대한 값을 관리하는 좌우 분할 패널 UI입니다.

핵심 기능:

  • 좌측: 카테고리 컬럼 목록 (값 개수 표시)
  • 우측: 선택된 카테고리의 값 관리
  • 값 추가/편집/삭제
  • 검색 및 필터링
  • 일괄 선택 및 일괄 삭제
  • 드래그앤드롭 순서 변경
  • 색상/아이콘 설정
  • 기본값 지정
  • 활성화/비활성화 관리

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