# 카테고리 관리 컴포넌트 구현 계획서 > **작성일**: 2025-11-04 > **목적**: 테이블의 카테고리 타입 컬럼별로 값을 관리하는 좌우 분할 패널 컴포넌트 --- ## 1. 개요 ### 1.1 카테고리 관리 컴포넌트란? 테이블의 **카테고리 타입 컬럼**에 대한 값(코드)를 관리하는 전용 컴포넌트입니다. ### 1.2 UI 구조 ``` ┌─────────────────────────────────────────────────────────────┐ │ 카테고리 관리 │ ├──────────────────┬──────────────────────────────────────────┤ │ 카테고리 목록 │ 선택된 카테고리 값 관리 │ │ (좌측 패널) │ (우측 패널) │ ├──────────────────┼──────────────────────────────────────────┤ │ │ │ │ ☑ 프로젝트 유형 │ 프로젝트 유형 값 관리 │ │ 프로젝트 상태 │ ┌────────────────────────────────┐ │ │ 우선순위 │ │ [검색창] [+ 새 값] │ │ │ 담당 부서 │ └────────────────────────────────┘ │ │ │ │ │ │ ┌─ 값 목록 ─────────────────────┐ │ │ │ │ □ DEV 개발 [편집][삭제] │ │ │ │ │ □ MAINT 유지보수 [편집][삭제] │ │ │ │ │ □ CONSULT 컨설팅 [편집][삭제] │ │ │ │ │ □ RESEARCH 연구개발 [편집][삭제] │ │ │ │ └──────────────────────────────┘ │ │ │ │ │ │ 선택된 항목: 2개 │ │ │ [일괄 활성화] [일괄 비활성화] [일괄 삭제] │ │ │ │ └──────────────────┴──────────────────────────────────────────┘ ``` ### 1.3 주요 기능 **좌측 패널 (카테고리 목록)**: - 현재 테이블의 카테고리 타입 컬럼들을 라벨명으로 표시 - 선택된 카테고리 강조 표시 - 각 카테고리별 값 개수 뱃지 표시 **우측 패널 (카테고리 값 관리)**: - 선택된 카테고리의 값 목록 표시 - 값 추가/편집/삭제 - 값 검색 및 필터링 - 값 정렬 순서 변경 (드래그앤드롭) - 일괄 선택 및 일괄 작업 - 활성화/비활성화 상태 관리 --- ## 2. 데이터 구조 ### 2.1 카테고리 타입이란? 테이블 설계 시 컬럼의 `web_type`을 `category`로 지정하면, 해당 컬럼은 미리 정의된 코드 값만 입력 가능합니다. **예시**: `projects` 테이블 | 컬럼명 | 웹타입 | 설명 | |--------|--------|------| | project_type | category | 프로젝트 유형 (개발, 유지보수, 컨설팅) | | project_status | category | 프로젝트 상태 (계획, 진행중, 완료) | | priority | category | 우선순위 (긴급, 높음, 보통, 낮음) | ### 2.2 데이터베이스 스키마 #### 테이블 컬럼 정의 (기존) ```sql -- 테이블 컬럼 정의 SELECT column_name, column_label, web_type FROM table_columns WHERE table_name = 'projects' AND web_type = 'category'; ``` #### 카테고리 값 저장 테이블 (신규) ```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, -- 코드 (예: 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 '기본값 여부 (자동 선택)'; ``` #### 샘플 데이터 ```sql -- 프로젝트 유형 카테고리 값 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 타입 정의 ```typescript // 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 서비스 레이어 ```typescript // 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 { 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 { 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 { 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, companyCode: string, userId: string ): Promise { const pool = getPool(); try { const setClauses: string[] = []; const values: any[] = []; let paramIndex = 1; if (updates.valueLabel !== undefined) { setClauses.push(`value_label = $${paramIndex++}`); values.push(updates.valueLabel); } if (updates.valueOrder !== undefined) { setClauses.push(`value_order = $${paramIndex++}`); values.push(updates.valueOrder); } if (updates.description !== undefined) { setClauses.push(`description = $${paramIndex++}`); values.push(updates.description); } if (updates.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 { 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 { 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 { 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 컨트롤러 레이어 ```typescript // 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 라우트 설정 ```typescript // 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; ``` ```typescript // backend-node/src/app.ts에 추가 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 라우트 등록 app.use("/api/table-categories", tableCategoryValueRoutes); ``` --- ## 4. 프론트엔드 구현 ### 4.1 타입 정의 ```typescript // 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 클라이언트 ```typescript // 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 ) { 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 카테고리 관리 메인 컴포넌트 ```typescript // 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 = ({ tableName, }) => { const { toast } = useToast(); const [columns, setColumns] = useState([]); const [selectedColumn, setSelectedColumn] = useState(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 ( 카테고리 관리 - {tableName} {/* 좌측: 카테고리 컬럼 목록 */} {/* 우측: 카테고리 값 관리 */} {selectedColumn ? ( handleValueCountUpdate(selectedColumn.columnName, count) } /> ) : (
좌측에서 카테고리를 선택하세요
)}
); }; ``` ### 4.4 좌측: 카테고리 컬럼 목록 ```typescript // 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 = ({ columns, selectedColumn, onColumnSelect, isLoading, }) => { if (isLoading) { return (
); } if (columns.length === 0) { return (

카테고리 타입 컬럼이 없습니다

); } return (

카테고리 목록

{columns.map((column) => ( ))}
); }; ``` ### 4.5 우측: 카테고리 값 관리 ```typescript // 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 = ({ tableName, columnName, columnLabel, onValueCountChange, }) => { const { toast } = useToast(); const [values, setValues] = useState([]); const [filteredValues, setFilteredValues] = useState([]); const [selectedValueIds, setSelectedValueIds] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [editingValue, setEditingValue] = useState(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) => { 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 (
{/* 헤더 */}

{columnLabel}

총 {filteredValues.length}개 항목

{/* 검색바 */}
setSearchQuery(e.target.value)} className="pl-9" />
{/* 값 목록 */}
{filteredValues.length === 0 ? (

{searchQuery ? "검색 결과가 없습니다" : "카테고리 값을 추가해주세요"}

) : (
{filteredValues.map((value) => (
handleSelectValue(value.valueId!)} />
{value.valueCode} {value.valueLabel} {value.isDefault && ( 기본값 )} {value.color && (
)}
{value.description && (

{value.description}

)}
{value.isActive ? ( ) : ( )}
))}
)}
{/* 푸터: 일괄 작업 */} {selectedValueIds.length > 0 && (
{selectedValueIds.length}개 선택됨
)} {/* 추가 다이얼로그 */} {/* 편집 다이얼로그 */} {editingValue && ( !open && setEditingValue(null)} value={editingValue} onUpdate={handleUpdateValue} columnLabel={columnLabel} /> )}
); }; ``` ### 4.6 값 추가 다이얼로그 ```typescript // 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 = ({ 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 ( 새 카테고리 값 추가 {columnLabel}에 새로운 값을 추가합니다
setValueCode(e.target.value.toUpperCase())} className="h-8 text-xs sm:h-10 sm:text-sm" />

영문 대문자와 언더스코어만 사용 (DB 저장값)

setValueLabel(e.target.value)} className="h-8 text-xs sm:h-10 sm:text-sm" />

사용자에게 표시될 이름