2321 lines
69 KiB
Markdown
2321 lines
69 KiB
Markdown
# 카테고리 관리 컴포넌트 구현 계획서
|
|
|
|
> **작성일**: 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<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 컨트롤러 레이어
|
|
|
|
```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<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 카테고리 관리 메인 컴포넌트
|
|
|
|
```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<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 좌측: 카테고리 컬럼 목록
|
|
|
|
```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<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 우측: 카테고리 값 관리
|
|
|
|
```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<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 값 추가 다이얼로그
|
|
|
|
```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<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 값 편집 다이얼로그
|
|
|
|
```typescript
|
|
// 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. 마이그레이션 파일
|
|
|
|
```sql
|
|
-- 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에 메뉴 추가
|
|
|
|
```typescript
|
|
// frontend/components/layout/AppLayout.tsx
|
|
|
|
const menuItems = [
|
|
// ... 기존 메뉴들
|
|
{
|
|
id: "table-category-management",
|
|
label: "카테고리 관리",
|
|
icon: Database,
|
|
path: "/table-categories",
|
|
requiresAuth: true,
|
|
},
|
|
];
|
|
```
|
|
|
|
### 페이지 생성
|
|
|
|
```typescript
|
|
// 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입니다.
|
|
|
|
**핵심 기능**:
|
|
- ✅ 좌측: 카테고리 컬럼 목록 (값 개수 표시)
|
|
- ✅ 우측: 선택된 카테고리의 값 관리
|
|
- ✅ 값 추가/편집/삭제
|
|
- ✅ 검색 및 필터링
|
|
- ✅ 일괄 선택 및 일괄 삭제
|
|
- ✅ 드래그앤드롭 순서 변경
|
|
- ✅ 색상/아이콘 설정
|
|
- ✅ 기본값 지정
|
|
- ✅ 활성화/비활성화 관리
|
|
|
|
이제 이 계획서를 기반으로 구현을 시작하시겠습니까?
|
|
|