1525 lines
43 KiB
Markdown
1525 lines
43 KiB
Markdown
# 카테고리 시스템 구현 계획서
|
|
|
|
> **작성일**: 2025-11-04
|
|
> **목적**: 메뉴별로 별도 관리되는 카테고리 코드 시스템 구현
|
|
|
|
---
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 카테고리 시스템이란?
|
|
|
|
**공통코드**와 유사하지만 **메뉴별로 독립적으로 관리**되는 코드 시스템입니다.
|
|
|
|
**주요 특징**:
|
|
- 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리
|
|
- 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계)
|
|
- 멀티테넌시: 회사별로 독립적으로 관리
|
|
- 동적 추가/수정: 관리자가 화면에서 직접 카테고리 추가/수정 가능
|
|
|
|
### 1.2 사용 예시
|
|
|
|
#### 프로젝트 관리 화면
|
|
- 프로젝트 유형: 개발, 유지보수, 컨설팅
|
|
- 프로젝트 상태: 계획, 진행중, 완료, 보류
|
|
- 우선순위: 긴급, 높음, 보통, 낮음
|
|
|
|
#### 계약관리 화면
|
|
- 계약 유형: 판매, 구매, 임대, 용역
|
|
- 계약 상태: 작성중, 검토중, 체결, 종료
|
|
- 결제 방식: 현금, 카드, 계좌이체, 어음
|
|
|
|
#### 자산관리 화면
|
|
- 자산 분류: IT장비, 비품, 차량, 부동산
|
|
- 자산 상태: 정상, 수리중, 폐기, 분실
|
|
- 위치: 본사, 지점A, 지점B, 창고
|
|
|
|
---
|
|
|
|
## 2. 데이터베이스 설계
|
|
|
|
### 2.1 카테고리 마스터 테이블
|
|
|
|
```sql
|
|
-- 메뉴별 카테고리 마스터
|
|
CREATE TABLE IF NOT EXISTS menu_categories (
|
|
category_id VARCHAR(50) PRIMARY KEY, -- 카테고리 ID (예: PROJ_TYPE, PROJ_STATUS)
|
|
category_name VARCHAR(100) NOT NULL, -- 카테고리명 (예: 프로젝트 유형)
|
|
menu_objid NUMERIC NOT NULL, -- 적용할 메뉴 OBJID
|
|
description TEXT, -- 설명
|
|
|
|
-- 설정
|
|
allow_custom_values BOOLEAN DEFAULT false, -- 사용자 정의 값 허용 여부
|
|
max_depth INTEGER DEFAULT 1, -- 최대 계층 깊이 (1~3)
|
|
is_multi_select BOOLEAN DEFAULT false, -- 다중 선택 가능 여부
|
|
|
|
-- 멀티테넌시
|
|
company_code VARCHAR(20) NOT NULL,
|
|
|
|
-- 메타 정보
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by VARCHAR(50),
|
|
|
|
CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid)
|
|
REFERENCES menu_info(objid),
|
|
CONSTRAINT fk_category_company FOREIGN KEY (company_code)
|
|
REFERENCES company_info(company_code)
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX idx_menu_categories_menu ON menu_categories(menu_objid);
|
|
CREATE INDEX idx_menu_categories_company ON menu_categories(company_code);
|
|
CREATE INDEX idx_menu_categories_scope ON menu_categories(company_code, menu_objid);
|
|
```
|
|
|
|
### 2.2 카테고리 값 테이블
|
|
|
|
```sql
|
|
-- 카테고리 값 (계층 구조 지원)
|
|
CREATE TABLE IF NOT EXISTS category_values (
|
|
value_id SERIAL PRIMARY KEY,
|
|
category_id VARCHAR(50) NOT NULL, -- 상위 카테고리 ID
|
|
value_code VARCHAR(50) NOT NULL, -- 값 코드 (예: DEV, MAINT, CONSULT)
|
|
value_label VARCHAR(100) NOT NULL, -- 값 라벨 (예: 개발, 유지보수, 컨설팅)
|
|
value_order INTEGER DEFAULT 0, -- 정렬 순서
|
|
|
|
-- 계층 구조
|
|
parent_value_id INTEGER, -- 상위 값 ID (NULL이면 최상위)
|
|
depth INTEGER DEFAULT 1, -- 계층 깊이 (1~3)
|
|
|
|
-- 추가 정보
|
|
description TEXT, -- 설명
|
|
is_active BOOLEAN DEFAULT true, -- 활성화 여부
|
|
|
|
-- 멀티테넌시
|
|
company_code VARCHAR(20) NOT NULL,
|
|
|
|
-- 메타 정보
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by VARCHAR(50),
|
|
|
|
CONSTRAINT fk_value_category FOREIGN KEY (category_id)
|
|
REFERENCES menu_categories(category_id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id)
|
|
REFERENCES category_values(value_id),
|
|
CONSTRAINT fk_value_company FOREIGN KEY (company_code)
|
|
REFERENCES company_info(company_code),
|
|
CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code)
|
|
);
|
|
|
|
-- 인덱스
|
|
CREATE INDEX idx_category_values_category ON category_values(category_id);
|
|
CREATE INDEX idx_category_values_parent ON category_values(parent_value_id);
|
|
CREATE INDEX idx_category_values_company ON category_values(company_code);
|
|
```
|
|
|
|
### 2.3 샘플 데이터
|
|
|
|
```sql
|
|
-- 샘플 카테고리: 프로젝트 유형 (메뉴 OBJID: 100)
|
|
INSERT INTO menu_categories (
|
|
category_id, category_name, menu_objid, description,
|
|
max_depth, company_code, created_by
|
|
) VALUES (
|
|
'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리',
|
|
2, 'COMPANY_A', 'admin'
|
|
);
|
|
|
|
-- 프로젝트 유형 값들
|
|
INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by)
|
|
VALUES
|
|
('PROJ_TYPE', 'DEV', '개발', 1, 'COMPANY_A', 'admin'),
|
|
('PROJ_TYPE', 'MAINT', '유지보수', 2, 'COMPANY_A', 'admin'),
|
|
('PROJ_TYPE', 'CONSULT', '컨설팅', 3, 'COMPANY_A', 'admin');
|
|
|
|
-- 샘플 카테고리: 프로젝트 상태 (메뉴 OBJID: 100)
|
|
INSERT INTO menu_categories (
|
|
category_id, category_name, menu_objid, description,
|
|
company_code, created_by
|
|
) VALUES (
|
|
'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태',
|
|
'COMPANY_A', 'admin'
|
|
);
|
|
|
|
-- 프로젝트 상태 값들
|
|
INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by)
|
|
VALUES
|
|
('PROJ_STATUS', 'PLAN', '계획', 1, 'COMPANY_A', 'admin'),
|
|
('PROJ_STATUS', 'PROGRESS', '진행중', 2, 'COMPANY_A', 'admin'),
|
|
('PROJ_STATUS', 'COMPLETE', '완료', 3, 'COMPANY_A', 'admin'),
|
|
('PROJ_STATUS', 'HOLD', '보류', 4, 'COMPANY_A', 'admin');
|
|
|
|
-- 샘플: 계층 구조 카테고리 (지역 → 도시 → 구)
|
|
INSERT INTO menu_categories (
|
|
category_id, category_name, menu_objid, description,
|
|
max_depth, company_code, created_by
|
|
) VALUES (
|
|
'REGION', '지역', 101, '지역/도시/구 계층 구조',
|
|
3, 'COMPANY_A', 'admin'
|
|
);
|
|
|
|
-- 1단계: 지역
|
|
INSERT INTO category_values (category_id, value_code, value_label, depth, value_order, company_code)
|
|
VALUES
|
|
('REGION', 'SEOUL', '서울특별시', 1, 1, 'COMPANY_A'),
|
|
('REGION', 'BUSAN', '부산광역시', 1, 2, 'COMPANY_A');
|
|
|
|
-- 2단계: 서울의 구
|
|
INSERT INTO category_values (category_id, value_code, value_label, parent_value_id, depth, value_order, company_code)
|
|
SELECT 'REGION', 'GANGNAM', '강남구', value_id, 2, 1, 'COMPANY_A'
|
|
FROM category_values WHERE value_code = 'SEOUL' AND category_id = 'REGION';
|
|
|
|
INSERT INTO category_values (category_id, value_code, value_label, parent_value_id, depth, value_order, company_code)
|
|
SELECT 'REGION', 'SONGPA', '송파구', value_id, 2, 2, 'COMPANY_A'
|
|
FROM category_values WHERE value_code = 'SEOUL' AND category_id = 'REGION';
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 백엔드 구현
|
|
|
|
### 3.1 타입 정의
|
|
|
|
```typescript
|
|
// backend-node/src/types/category.ts
|
|
|
|
export interface CategoryConfig {
|
|
categoryId: string;
|
|
categoryName: string;
|
|
menuObjid: number;
|
|
description?: string;
|
|
|
|
// 설정
|
|
allowCustomValues?: boolean;
|
|
maxDepth?: number;
|
|
isMultiSelect?: boolean;
|
|
|
|
// 멀티테넌시
|
|
companyCode?: string;
|
|
|
|
// 메타
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
createdBy?: string;
|
|
}
|
|
|
|
export interface CategoryValue {
|
|
valueId?: number;
|
|
categoryId: string;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
valueOrder?: number;
|
|
|
|
// 계층
|
|
parentValueId?: number;
|
|
depth?: number;
|
|
|
|
// 추가 정보
|
|
description?: string;
|
|
isActive?: boolean;
|
|
|
|
// 하위 항목 (조회 시)
|
|
children?: CategoryValue[];
|
|
|
|
// 멀티테넌시
|
|
companyCode?: string;
|
|
|
|
// 메타
|
|
createdAt?: string;
|
|
createdBy?: string;
|
|
}
|
|
```
|
|
|
|
### 3.2 서비스 레이어
|
|
|
|
```typescript
|
|
// backend-node/src/services/categoryService.ts
|
|
|
|
import { getPool } from "../config/database";
|
|
import logger from "../config/logger";
|
|
import { CategoryConfig, CategoryValue } from "../types/category";
|
|
|
|
class CategoryService {
|
|
/**
|
|
* 메뉴별 카테고리 목록 조회
|
|
*/
|
|
async getCategoriesByMenu(
|
|
companyCode: string,
|
|
menuObjid: number
|
|
): Promise<CategoryConfig[]> {
|
|
try {
|
|
logger.info("메뉴별 카테고리 조회 시작", { companyCode, menuObjid });
|
|
|
|
const pool = getPool();
|
|
const query = `
|
|
SELECT
|
|
category_id AS "categoryId",
|
|
category_name AS "categoryName",
|
|
menu_objid AS "menuObjid",
|
|
description,
|
|
allow_custom_values AS "allowCustomValues",
|
|
max_depth AS "maxDepth",
|
|
is_multi_select AS "isMultiSelect",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt",
|
|
created_by AS "createdBy"
|
|
FROM menu_categories
|
|
WHERE (company_code = $1 OR company_code = '*')
|
|
AND menu_objid = $2
|
|
ORDER BY category_name
|
|
`;
|
|
|
|
const result = await pool.query(query, [companyCode, menuObjid]);
|
|
|
|
logger.info(`메뉴별 카테고리 조회 완료: ${result.rows.length}개`, {
|
|
companyCode,
|
|
menuObjid,
|
|
});
|
|
|
|
return result.rows;
|
|
} catch (error: any) {
|
|
logger.error(`메뉴별 카테고리 조회 중 에러: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 값 조회 (계층 구조)
|
|
*/
|
|
async getCategoryValues(
|
|
categoryId: string,
|
|
companyCode: string
|
|
): Promise<CategoryValue[]> {
|
|
try {
|
|
logger.info("카테고리 값 조회 시작", { categoryId, companyCode });
|
|
|
|
const pool = getPool();
|
|
const query = `
|
|
SELECT
|
|
value_id AS "valueId",
|
|
category_id AS "categoryId",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
is_active AS "isActive",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
FROM category_values
|
|
WHERE category_id = $1
|
|
AND (company_code = $2 OR company_code = '*')
|
|
AND is_active = true
|
|
ORDER BY value_order, value_label
|
|
`;
|
|
|
|
const result = await pool.query(query, [categoryId, companyCode]);
|
|
|
|
// 계층 구조로 변환
|
|
const values = this.buildHierarchy(result.rows);
|
|
|
|
logger.info(`카테고리 값 조회 완료: ${result.rows.length}개`, {
|
|
categoryId,
|
|
companyCode,
|
|
});
|
|
|
|
return values;
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 조회 중 에러: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 생성
|
|
*/
|
|
async createCategory(
|
|
config: CategoryConfig,
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<CategoryConfig> {
|
|
const pool = getPool();
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query("BEGIN");
|
|
|
|
const insertQuery = `
|
|
INSERT INTO menu_categories (
|
|
category_id, category_name, menu_objid, description,
|
|
allow_custom_values, max_depth, is_multi_select,
|
|
company_code, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING
|
|
category_id AS "categoryId",
|
|
category_name AS "categoryName",
|
|
menu_objid AS "menuObjid",
|
|
description,
|
|
allow_custom_values AS "allowCustomValues",
|
|
max_depth AS "maxDepth",
|
|
is_multi_select AS "isMultiSelect",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
`;
|
|
|
|
const result = await client.query(insertQuery, [
|
|
config.categoryId,
|
|
config.categoryName,
|
|
config.menuObjid,
|
|
config.description || null,
|
|
config.allowCustomValues || false,
|
|
config.maxDepth || 1,
|
|
config.isMultiSelect || false,
|
|
companyCode,
|
|
userId,
|
|
]);
|
|
|
|
await client.query("COMMIT");
|
|
|
|
logger.info("카테고리 생성 완료", {
|
|
categoryId: config.categoryId,
|
|
companyCode,
|
|
});
|
|
|
|
return result.rows[0];
|
|
} catch (error: any) {
|
|
await client.query("ROLLBACK");
|
|
logger.error(`카테고리 생성 중 에러: ${error.message}`);
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 추가
|
|
*/
|
|
async addCategoryValue(
|
|
value: CategoryValue,
|
|
companyCode: string,
|
|
userId: string
|
|
): Promise<CategoryValue> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const insertQuery = `
|
|
INSERT INTO category_values (
|
|
category_id, value_code, value_label, value_order,
|
|
parent_value_id, depth, description, is_active,
|
|
company_code, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
RETURNING
|
|
value_id AS "valueId",
|
|
category_id AS "categoryId",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
is_active AS "isActive",
|
|
company_code AS "companyCode",
|
|
created_at AS "createdAt",
|
|
created_by AS "createdBy"
|
|
`;
|
|
|
|
const result = await pool.query(insertQuery, [
|
|
value.categoryId,
|
|
value.valueCode,
|
|
value.valueLabel,
|
|
value.valueOrder || 0,
|
|
value.parentValueId || null,
|
|
value.depth || 1,
|
|
value.description || null,
|
|
value.isActive !== false,
|
|
companyCode,
|
|
userId,
|
|
]);
|
|
|
|
logger.info("카테고리 값 추가 완료", {
|
|
valueId: result.rows[0].valueId,
|
|
categoryId: value.categoryId,
|
|
});
|
|
|
|
return result.rows[0];
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 추가 중 에러: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 수정
|
|
*/
|
|
async updateCategoryValue(
|
|
valueId: number,
|
|
updates: Partial<CategoryValue>,
|
|
companyCode: string
|
|
): Promise<CategoryValue> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
const setClauses: string[] = [];
|
|
const values: any[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (updates.valueLabel !== undefined) {
|
|
setClauses.push(`value_label = $${paramIndex++}`);
|
|
values.push(updates.valueLabel);
|
|
}
|
|
|
|
if (updates.valueOrder !== undefined) {
|
|
setClauses.push(`value_order = $${paramIndex++}`);
|
|
values.push(updates.valueOrder);
|
|
}
|
|
|
|
if (updates.description !== undefined) {
|
|
setClauses.push(`description = $${paramIndex++}`);
|
|
values.push(updates.description);
|
|
}
|
|
|
|
if (updates.isActive !== undefined) {
|
|
setClauses.push(`is_active = $${paramIndex++}`);
|
|
values.push(updates.isActive);
|
|
}
|
|
|
|
setClauses.push(`updated_at = NOW()`);
|
|
|
|
values.push(valueId, companyCode);
|
|
|
|
const updateQuery = `
|
|
UPDATE category_values
|
|
SET ${setClauses.join(", ")}
|
|
WHERE value_id = $${paramIndex++}
|
|
AND (company_code = $${paramIndex++} OR company_code = '*')
|
|
RETURNING
|
|
value_id AS "valueId",
|
|
category_id AS "categoryId",
|
|
value_code AS "valueCode",
|
|
value_label AS "valueLabel",
|
|
value_order AS "valueOrder",
|
|
parent_value_id AS "parentValueId",
|
|
depth,
|
|
description,
|
|
is_active AS "isActive"
|
|
`;
|
|
|
|
const result = await pool.query(updateQuery, values);
|
|
|
|
if (result.rowCount === 0) {
|
|
throw new Error("카테고리 값을 찾을 수 없습니다");
|
|
}
|
|
|
|
logger.info("카테고리 값 수정 완료", { valueId, companyCode });
|
|
|
|
return result.rows[0];
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 수정 중 에러: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 삭제 (비활성화)
|
|
*/
|
|
async deleteCategoryValue(
|
|
valueId: number,
|
|
companyCode: string
|
|
): Promise<void> {
|
|
const pool = getPool();
|
|
|
|
try {
|
|
// 하위 값이 있는지 확인
|
|
const checkQuery = `
|
|
SELECT COUNT(*) as count
|
|
FROM category_values
|
|
WHERE parent_value_id = $1
|
|
AND (company_code = $2 OR company_code = '*')
|
|
AND is_active = true
|
|
`;
|
|
|
|
const checkResult = await pool.query(checkQuery, [valueId, companyCode]);
|
|
|
|
if (parseInt(checkResult.rows[0].count) > 0) {
|
|
throw new Error(
|
|
"하위 카테고리 값이 있어 삭제할 수 없습니다"
|
|
);
|
|
}
|
|
|
|
// 비활성화
|
|
const deleteQuery = `
|
|
UPDATE category_values
|
|
SET is_active = false, updated_at = NOW()
|
|
WHERE value_id = $1
|
|
AND (company_code = $2 OR company_code = '*')
|
|
`;
|
|
|
|
await pool.query(deleteQuery, [valueId, companyCode]);
|
|
|
|
logger.info("카테고리 값 삭제(비활성화) 완료", {
|
|
valueId,
|
|
companyCode,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 삭제 중 에러: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 계층 구조 변환 헬퍼 함수
|
|
*/
|
|
private buildHierarchy(
|
|
values: CategoryValue[],
|
|
parentId: number | null = null
|
|
): CategoryValue[] {
|
|
return values
|
|
.filter((v) => v.parentValueId === parentId)
|
|
.map((v) => ({
|
|
...v,
|
|
children: this.buildHierarchy(values, v.valueId!),
|
|
}));
|
|
}
|
|
}
|
|
|
|
export default new CategoryService();
|
|
```
|
|
|
|
### 3.3 컨트롤러 레이어
|
|
|
|
```typescript
|
|
// backend-node/src/controllers/categoryController.ts
|
|
|
|
import { Request, Response } from "express";
|
|
import categoryService from "../services/categoryService";
|
|
import logger from "../config/logger";
|
|
|
|
/**
|
|
* 메뉴별 카테고리 목록 조회
|
|
*/
|
|
export const getCategoriesByMenu = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const menuObjid = parseInt(req.params.menuObjid);
|
|
|
|
if (isNaN(menuObjid)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "유효하지 않은 메뉴 ID입니다",
|
|
});
|
|
}
|
|
|
|
const categories = await categoryService.getCategoriesByMenu(
|
|
companyCode,
|
|
menuObjid
|
|
);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: categories,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`메뉴별 카테고리 조회 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 조회 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 카테고리 값 조회
|
|
*/
|
|
export const getCategoryValues = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const { categoryId } = req.params;
|
|
|
|
const values = await categoryService.getCategoryValues(
|
|
categoryId,
|
|
companyCode
|
|
);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: values,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 조회 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 조회 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 카테고리 생성
|
|
*/
|
|
export const createCategory = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const config = req.body;
|
|
|
|
const newCategory = await categoryService.createCategory(
|
|
config,
|
|
companyCode,
|
|
userId
|
|
);
|
|
|
|
return res.status(201).json({
|
|
success: true,
|
|
data: newCategory,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 생성 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 생성 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 카테고리 값 추가
|
|
*/
|
|
export const addCategoryValue = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const userId = req.user!.userId;
|
|
const value = req.body;
|
|
|
|
const newValue = await categoryService.addCategoryValue(
|
|
value,
|
|
companyCode,
|
|
userId
|
|
);
|
|
|
|
return res.status(201).json({
|
|
success: true,
|
|
data: newValue,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 추가 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 추가 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 카테고리 값 수정
|
|
*/
|
|
export const updateCategoryValue = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const valueId = parseInt(req.params.valueId);
|
|
const updates = req.body;
|
|
|
|
if (isNaN(valueId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "유효하지 않은 값 ID입니다",
|
|
});
|
|
}
|
|
|
|
const updatedValue = await categoryService.updateCategoryValue(
|
|
valueId,
|
|
updates,
|
|
companyCode
|
|
);
|
|
|
|
return res.json({
|
|
success: true,
|
|
data: updatedValue,
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 수정 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 수정 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 카테고리 값 삭제
|
|
*/
|
|
export const deleteCategoryValue = async (req: Request, res: Response) => {
|
|
try {
|
|
const companyCode = req.user!.companyCode;
|
|
const valueId = parseInt(req.params.valueId);
|
|
|
|
if (isNaN(valueId)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: "유효하지 않은 값 ID입니다",
|
|
});
|
|
}
|
|
|
|
await categoryService.deleteCategoryValue(valueId, companyCode);
|
|
|
|
return res.json({
|
|
success: true,
|
|
message: "카테고리 값이 삭제되었습니다",
|
|
});
|
|
} catch (error: any) {
|
|
logger.error(`카테고리 값 삭제 실패: ${error.message}`);
|
|
return res.status(500).json({
|
|
success: false,
|
|
message: "카테고리 값 삭제 중 오류가 발생했습니다",
|
|
error: error.message,
|
|
});
|
|
}
|
|
};
|
|
```
|
|
|
|
### 3.4 라우트 설정
|
|
|
|
```typescript
|
|
// backend-node/src/routes/categoryRoutes.ts
|
|
|
|
import { Router } from "express";
|
|
import * as categoryController from "../controllers/categoryController";
|
|
import { authenticate } from "../middleware/authMiddleware";
|
|
|
|
const router = Router();
|
|
|
|
// 모든 라우트에 인증 미들웨어 적용
|
|
router.use(authenticate);
|
|
|
|
// 메뉴별 카테고리 목록 조회
|
|
router.get("/menu/:menuObjid", categoryController.getCategoriesByMenu);
|
|
|
|
// 카테고리 값 조회
|
|
router.get("/:categoryId/values", categoryController.getCategoryValues);
|
|
|
|
// 카테고리 생성
|
|
router.post("/", categoryController.createCategory);
|
|
|
|
// 카테고리 값 추가
|
|
router.post("/values", categoryController.addCategoryValue);
|
|
|
|
// 카테고리 값 수정
|
|
router.put("/values/:valueId", categoryController.updateCategoryValue);
|
|
|
|
// 카테고리 값 삭제
|
|
router.delete("/values/:valueId", categoryController.deleteCategoryValue);
|
|
|
|
export default router;
|
|
```
|
|
|
|
```typescript
|
|
// backend-node/src/app.ts에 추가
|
|
|
|
import categoryRoutes from "./routes/categoryRoutes";
|
|
|
|
// 라우트 등록
|
|
app.use("/api/categories", categoryRoutes);
|
|
```
|
|
|
|
---
|
|
|
|
## 4. 프론트엔드 구현
|
|
|
|
### 4.1 타입 정의
|
|
|
|
```typescript
|
|
// frontend/types/category.ts
|
|
|
|
export interface CategoryConfig {
|
|
categoryId: string;
|
|
categoryName: string;
|
|
menuObjid: number;
|
|
description?: string;
|
|
|
|
// 설정
|
|
allowCustomValues?: boolean;
|
|
maxDepth?: number;
|
|
isMultiSelect?: boolean;
|
|
|
|
// 멀티테넌시
|
|
companyCode?: string;
|
|
|
|
// 메타
|
|
createdAt?: string;
|
|
updatedAt?: string;
|
|
createdBy?: string;
|
|
}
|
|
|
|
export interface CategoryValue {
|
|
valueId?: number;
|
|
categoryId: string;
|
|
valueCode: string;
|
|
valueLabel: string;
|
|
valueOrder?: number;
|
|
|
|
// 계층
|
|
parentValueId?: number;
|
|
depth?: number;
|
|
|
|
// 추가 정보
|
|
description?: string;
|
|
isActive?: boolean;
|
|
|
|
// 하위 항목
|
|
children?: CategoryValue[];
|
|
|
|
// 멀티테넌시
|
|
companyCode?: string;
|
|
|
|
// 메타
|
|
createdAt?: string;
|
|
createdBy?: string;
|
|
}
|
|
```
|
|
|
|
### 4.2 API 클라이언트
|
|
|
|
```typescript
|
|
// frontend/lib/api/category.ts
|
|
|
|
import apiClient from "./client";
|
|
import { CategoryConfig, CategoryValue } from "@/types/category";
|
|
|
|
/**
|
|
* 메뉴별 카테고리 목록 조회
|
|
*/
|
|
export async function getCategoriesByMenu(menuObjid: number) {
|
|
try {
|
|
const response = await apiClient.get<{ success: boolean; data: CategoryConfig[] }>(
|
|
`/api/categories/menu/${menuObjid}`
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 목록 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 조회
|
|
*/
|
|
export async function getCategoryValues(categoryId: string) {
|
|
try {
|
|
const response = await apiClient.get<{ success: boolean; data: CategoryValue[] }>(
|
|
`/api/categories/${categoryId}/values`
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 조회 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 생성
|
|
*/
|
|
export async function createCategory(config: CategoryConfig) {
|
|
try {
|
|
const response = await apiClient.post<{ success: boolean; data: CategoryConfig }>(
|
|
"/api/categories",
|
|
config
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 생성 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 추가
|
|
*/
|
|
export async function addCategoryValue(value: CategoryValue) {
|
|
try {
|
|
const response = await apiClient.post<{ success: boolean; data: CategoryValue }>(
|
|
"/api/categories/values",
|
|
value
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 추가 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 수정
|
|
*/
|
|
export async function updateCategoryValue(valueId: number, updates: Partial<CategoryValue>) {
|
|
try {
|
|
const response = await apiClient.put<{ success: boolean; data: CategoryValue }>(
|
|
`/api/categories/values/${valueId}`,
|
|
updates
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 수정 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 카테고리 값 삭제
|
|
*/
|
|
export async function deleteCategoryValue(valueId: number) {
|
|
try {
|
|
const response = await apiClient.delete<{ success: boolean; message: string }>(
|
|
`/api/categories/values/${valueId}`
|
|
);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
console.error("카테고리 값 삭제 실패:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.3 웹타입 확장
|
|
|
|
```typescript
|
|
// frontend/types/screen.ts에 추가
|
|
|
|
export type WebType =
|
|
| "text"
|
|
| "number"
|
|
| "decimal"
|
|
| "date"
|
|
| "datetime"
|
|
| "select"
|
|
| "entity"
|
|
| "category" // 신규 추가
|
|
| "textarea"
|
|
| "boolean"
|
|
| "checkbox"
|
|
| "radio"
|
|
| "code"
|
|
| "file"
|
|
| "email"
|
|
| "tel"
|
|
| "button";
|
|
```
|
|
|
|
### 4.4 카테고리 설정 패널
|
|
|
|
```typescript
|
|
// frontend/components/screen/panels/webtype-configs/CategoryTypeConfigPanel.tsx
|
|
|
|
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Plus, X, ChevronRight } from "lucide-react";
|
|
import { getCategoriesByMenu, getCategoryValues, addCategoryValue } from "@/lib/api/category";
|
|
import { CategoryConfig, CategoryValue } from "@/types/category";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface CategoryTypeConfigPanelProps {
|
|
config: any;
|
|
onUpdate: (updates: any) => void;
|
|
menuObjid?: number;
|
|
}
|
|
|
|
export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = ({
|
|
config,
|
|
onUpdate,
|
|
menuObjid,
|
|
}) => {
|
|
const { toast } = useToast();
|
|
const [categories, setCategories] = useState<CategoryConfig[]>([]);
|
|
const [selectedCategory, setSelectedCategory] = useState<string>(config.categoryId || "");
|
|
const [categoryValues, setCategoryValues] = useState<CategoryValue[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
// 신규 값 추가 상태
|
|
const [isAddingValue, setIsAddingValue] = useState(false);
|
|
const [newValueCode, setNewValueCode] = useState("");
|
|
const [newValueLabel, setNewValueLabel] = useState("");
|
|
|
|
// 메뉴별 카테고리 목록 로드
|
|
useEffect(() => {
|
|
if (menuObjid) {
|
|
loadCategories();
|
|
}
|
|
}, [menuObjid]);
|
|
|
|
// 선택된 카테고리의 값 로드
|
|
useEffect(() => {
|
|
if (selectedCategory) {
|
|
loadCategoryValues();
|
|
}
|
|
}, [selectedCategory]);
|
|
|
|
const loadCategories = async () => {
|
|
if (!menuObjid) return;
|
|
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await getCategoriesByMenu(menuObjid);
|
|
if (response.success && response.data) {
|
|
setCategories(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 목록 로드 실패:", error);
|
|
toast({
|
|
title: "오류",
|
|
description: "카테고리 목록을 불러올 수 없습니다",
|
|
variant: "destructive",
|
|
});
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadCategoryValues = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await getCategoryValues(selectedCategory);
|
|
if (response.success && response.data) {
|
|
setCategoryValues(response.data);
|
|
}
|
|
} catch (error) {
|
|
console.error("카테고리 값 로드 실패:", error);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCategoryChange = (categoryId: string) => {
|
|
setSelectedCategory(categoryId);
|
|
onUpdate({
|
|
categoryId,
|
|
values: [],
|
|
});
|
|
};
|
|
|
|
const handleAddValue = async () => {
|
|
if (!newValueCode || !newValueLabel) {
|
|
toast({
|
|
title: "입력 오류",
|
|
description: "코드와 라벨을 모두 입력해주세요",
|
|
variant: "destructive",
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const newValue: CategoryValue = {
|
|
categoryId: selectedCategory,
|
|
valueCode: newValueCode,
|
|
valueLabel: newValueLabel,
|
|
valueOrder: categoryValues.length + 1,
|
|
};
|
|
|
|
const response = await addCategoryValue(newValue);
|
|
|
|
if (response.success && response.data) {
|
|
setCategoryValues([...categoryValues, response.data]);
|
|
setNewValueCode("");
|
|
setNewValueLabel("");
|
|
setIsAddingValue(false);
|
|
|
|
toast({
|
|
title: "성공",
|
|
description: "카테고리 값이 추가되었습니다",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: "오류",
|
|
description: "카테고리 값 추가에 실패했습니다",
|
|
variant: "destructive",
|
|
});
|
|
}
|
|
};
|
|
|
|
const renderCategoryValues = (values: CategoryValue[], depth: number = 0) => {
|
|
return values.map((value) => (
|
|
<div key={value.valueId} className={`ml-${depth * 4}`}>
|
|
<div className="flex items-center gap-2 p-2 hover:bg-accent rounded-md">
|
|
{depth > 0 && <ChevronRight className="h-3 w-3" />}
|
|
<Badge variant="outline" className="text-xs">
|
|
{value.valueCode}
|
|
</Badge>
|
|
<span className="text-sm">{value.valueLabel}</span>
|
|
</div>
|
|
{value.children && value.children.length > 0 && renderCategoryValues(value.children, depth + 1)}
|
|
</div>
|
|
));
|
|
};
|
|
|
|
const selectedCategoryConfig = categories.find((c) => c.categoryId === selectedCategory);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 카테고리 선택 */}
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">카테고리 선택</Label>
|
|
<Select value={selectedCategory} onValueChange={handleCategoryChange}>
|
|
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
|
|
<SelectValue placeholder="카테고리를 선택하세요" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{categories.map((cat) => (
|
|
<SelectItem key={cat.categoryId} value={cat.categoryId} className="text-xs sm:text-sm">
|
|
{cat.categoryName}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{selectedCategoryConfig?.description && (
|
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
|
{selectedCategoryConfig.description}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* 다중 선택 허용 */}
|
|
{selectedCategory && (
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="multiSelect"
|
|
checked={config.isMultiSelect || false}
|
|
onCheckedChange={(checked) => onUpdate({ isMultiSelect: checked as boolean })}
|
|
/>
|
|
<Label htmlFor="multiSelect" className="text-xs sm:text-sm">
|
|
다중 선택 허용
|
|
</Label>
|
|
</div>
|
|
)}
|
|
|
|
{/* 카테고리 값 목록 */}
|
|
{selectedCategory && categoryValues.length > 0 && (
|
|
<div>
|
|
<Label className="text-xs font-medium sm:text-sm">카테고리 값 목록</Label>
|
|
<div className="mt-2 max-h-64 overflow-y-auto rounded-md border p-2">
|
|
{renderCategoryValues(categoryValues)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 새 값 추가 */}
|
|
{selectedCategory && selectedCategoryConfig?.allowCustomValues && (
|
|
<div>
|
|
{!isAddingValue ? (
|
|
<Button variant="outline" size="sm" onClick={() => setIsAddingValue(true)} className="w-full">
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
새 값 추가
|
|
</Button>
|
|
) : (
|
|
<div className="space-y-2 rounded-md border p-3">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs font-medium sm:text-sm">새 카테고리 값</Label>
|
|
<Button variant="ghost" size="icon" onClick={() => setIsAddingValue(false)} className="h-6 w-6">
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
<Input
|
|
placeholder="코드 (예: NEW_CODE)"
|
|
value={newValueCode}
|
|
onChange={(e) => setNewValueCode(e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Input
|
|
placeholder="라벨 (예: 새 항목)"
|
|
value={newValueLabel}
|
|
onChange={(e) => setNewValueLabel(e.target.value)}
|
|
className="h-8 text-xs"
|
|
/>
|
|
<Button onClick={handleAddValue} size="sm" className="w-full">
|
|
추가
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 사용 가능 옵션 표시 */}
|
|
{selectedCategoryConfig && (
|
|
<div className="rounded-md bg-muted p-3 text-xs">
|
|
<div className="flex gap-2">
|
|
<Badge variant={selectedCategoryConfig.allowCustomValues ? "default" : "secondary"} className="text-[10px]">
|
|
{selectedCategoryConfig.allowCustomValues ? "사용자 정의 허용" : "고정 값"}
|
|
</Badge>
|
|
<Badge variant="outline" className="text-[10px]">
|
|
최대 깊이: {selectedCategoryConfig.maxDepth}단계
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
### 4.5 웹타입 설정 통합
|
|
|
|
```typescript
|
|
// frontend/components/screen/panels/DynamicComponentConfigPanel.tsx에 추가
|
|
|
|
import { CategoryTypeConfigPanel } from "./webtype-configs/CategoryTypeConfigPanel";
|
|
|
|
// getComponentConfigPanel 함수에 추가
|
|
case "category":
|
|
return (
|
|
<CategoryTypeConfigPanel
|
|
config={component.webTypeConfig || {}}
|
|
onUpdate={(updates) => {
|
|
const newConfig = {
|
|
...(component.webTypeConfig || {}),
|
|
...updates,
|
|
};
|
|
onUpdateComponent({ webTypeConfig: newConfig });
|
|
}}
|
|
menuObjid={currentMenuObjid} // 현재 화면의 메뉴 OBJID
|
|
/>
|
|
);
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 화면에서 카테고리 렌더링
|
|
|
|
### 5.1 RealtimePreview에 카테고리 타입 추가
|
|
|
|
```typescript
|
|
// frontend/components/screen/RealtimePreview.tsx
|
|
|
|
case "category":
|
|
return (
|
|
<Select
|
|
value={component.webTypeConfig?.selectedValue || ""}
|
|
onValueChange={(value) => {
|
|
// 값 선택 시 처리
|
|
console.log("선택된 카테고리 값:", value);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
className="w-full"
|
|
style={{
|
|
fontSize: component.style?.fontSize || "14px",
|
|
height: component.height ? `${component.height}px` : undefined,
|
|
}}
|
|
>
|
|
<SelectValue placeholder={component.placeholder || "선택하세요"} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{renderCategoryOptions(component.webTypeConfig?.values || [])}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
|
|
// 계층 구조 렌더링 헬퍼 함수
|
|
const renderCategoryOptions = (values: CategoryValue[], depth: number = 0) => {
|
|
return values.map((value) => (
|
|
<React.Fragment key={value.valueId}>
|
|
<SelectItem value={value.valueCode} className="text-xs sm:text-sm">
|
|
{" ".repeat(depth)}
|
|
{value.valueLabel}
|
|
</SelectItem>
|
|
{value.children && value.children.length > 0 && renderCategoryOptions(value.children, depth + 1)}
|
|
</React.Fragment>
|
|
));
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 6. 사용 시나리오
|
|
|
|
### 시나리오 1: 프로젝트 관리 화면에서 카테고리 사용
|
|
|
|
1. **관리자가 카테고리 생성**
|
|
- 화면관리에서 "프로젝트 관리" 화면 선택
|
|
- 카테고리 관리 메뉴로 이동
|
|
- "프로젝트 유형" 카테고리 생성
|
|
- 값 추가: 개발, 유지보수, 컨설팅
|
|
|
|
2. **화면에 카테고리 컴포넌트 배치**
|
|
- 위젯 추가 → 웹타입: "category" 선택
|
|
- 카테고리 선택: "프로젝트 유형"
|
|
- 라벨 설정: "프로젝트 유형"
|
|
|
|
3. **사용자가 화면에서 선택**
|
|
- 프로젝트 등록 화면 접속
|
|
- "프로젝트 유형" 드롭다운에서 "개발" 선택
|
|
- 저장 시 선택된 코드(DEV)가 데이터베이스에 저장됨
|
|
|
|
### 시나리오 2: 계층 구조 카테고리
|
|
|
|
1. **관리자가 3단계 카테고리 생성**
|
|
- "지역" 카테고리 생성 (maxDepth: 3)
|
|
- 1단계: 서울특별시, 부산광역시
|
|
- 2단계: 강남구, 송파구 (서울 하위)
|
|
- 3단계: 역삼동, 삼성동 (강남구 하위)
|
|
|
|
2. **화면에서 계층 선택**
|
|
- 사용자가 "서울특별시" 선택 → 강남구, 송파구 표시
|
|
- 사용자가 "강남구" 선택 → 역삼동, 삼성동 표시
|
|
|
|
---
|
|
|
|
## 7. 마이그레이션 파일
|
|
|
|
```sql
|
|
-- db/migrations/036_create_menu_categories.sql
|
|
|
|
-- 1. 카테고리 마스터 테이블 생성
|
|
CREATE TABLE IF NOT EXISTS menu_categories (
|
|
category_id VARCHAR(50) PRIMARY KEY,
|
|
category_name VARCHAR(100) NOT NULL,
|
|
menu_objid NUMERIC NOT NULL,
|
|
description TEXT,
|
|
|
|
allow_custom_values BOOLEAN DEFAULT false,
|
|
max_depth INTEGER DEFAULT 1,
|
|
is_multi_select BOOLEAN DEFAULT false,
|
|
|
|
company_code VARCHAR(20) NOT NULL,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by VARCHAR(50),
|
|
|
|
CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid)
|
|
REFERENCES menu_info(objid),
|
|
CONSTRAINT fk_category_company FOREIGN KEY (company_code)
|
|
REFERENCES company_info(company_code)
|
|
);
|
|
|
|
-- 2. 카테고리 값 테이블 생성
|
|
CREATE TABLE IF NOT EXISTS category_values (
|
|
value_id SERIAL PRIMARY KEY,
|
|
category_id VARCHAR(50) NOT NULL,
|
|
value_code VARCHAR(50) NOT NULL,
|
|
value_label VARCHAR(100) NOT NULL,
|
|
value_order INTEGER DEFAULT 0,
|
|
|
|
parent_value_id INTEGER,
|
|
depth INTEGER DEFAULT 1,
|
|
|
|
description TEXT,
|
|
is_active BOOLEAN DEFAULT true,
|
|
|
|
company_code VARCHAR(20) NOT NULL,
|
|
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
created_by VARCHAR(50),
|
|
|
|
CONSTRAINT fk_value_category FOREIGN KEY (category_id)
|
|
REFERENCES menu_categories(category_id) ON DELETE CASCADE,
|
|
CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id)
|
|
REFERENCES category_values(value_id),
|
|
CONSTRAINT fk_value_company FOREIGN KEY (company_code)
|
|
REFERENCES company_info(company_code),
|
|
CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code)
|
|
);
|
|
|
|
-- 3. 인덱스 생성
|
|
CREATE INDEX idx_menu_categories_menu ON menu_categories(menu_objid);
|
|
CREATE INDEX idx_menu_categories_company ON menu_categories(company_code);
|
|
CREATE INDEX idx_menu_categories_scope ON menu_categories(company_code, menu_objid);
|
|
CREATE INDEX idx_category_values_category ON category_values(category_id);
|
|
CREATE INDEX idx_category_values_parent ON category_values(parent_value_id);
|
|
CREATE INDEX idx_category_values_company ON category_values(company_code);
|
|
|
|
-- 4. 코멘트 추가
|
|
COMMENT ON TABLE menu_categories IS '메뉴별 카테고리 마스터';
|
|
COMMENT ON TABLE category_values IS '카테고리 값 (계층 구조 지원)';
|
|
|
|
-- 5. 샘플 데이터
|
|
-- (위 2.3 섹션의 샘플 데이터 삽입 쿼리)
|
|
|
|
-- 완료 메시지
|
|
SELECT 'Migration 036: Menu Categories created successfully!' AS status;
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 구현 체크리스트
|
|
|
|
### 데이터베이스 ✅
|
|
- [ ] 마이그레이션 파일 작성 (036_create_menu_categories.sql)
|
|
- [ ] 테이블 생성 및 인덱스
|
|
- [ ] 샘플 데이터 삽입
|
|
- [ ] 외래키 제약조건 설정
|
|
|
|
### 백엔드 ✅
|
|
- [ ] 타입 정의 (backend-node/src/types/category.ts)
|
|
- [ ] 서비스 레이어 (categoryService.ts)
|
|
- [ ] 컨트롤러 레이어 (categoryController.ts)
|
|
- [ ] 라우트 설정 (categoryRoutes.ts)
|
|
- [ ] app.ts에 라우트 등록
|
|
|
|
### 프론트엔드 ✅
|
|
- [ ] 타입 정의 (frontend/types/category.ts)
|
|
- [ ] API 클라이언트 (frontend/lib/api/category.ts)
|
|
- [ ] WebType에 "category" 추가
|
|
- [ ] CategoryTypeConfigPanel 컴포넌트
|
|
- [ ] DynamicComponentConfigPanel 통합
|
|
- [ ] RealtimePreview에 렌더링 로직 추가
|
|
|
|
### 테스트 ✅
|
|
- [ ] 카테고리 생성/조회/수정/삭제 API 테스트
|
|
- [ ] 메뉴별 카테고리 필터링 테스트
|
|
- [ ] 계층 구조 렌더링 테스트
|
|
- [ ] 멀티테넌시 격리 테스트
|
|
|
|
---
|
|
|
|
## 9. 향후 확장 가능성
|
|
|
|
### 9.1 동적 카테고리 검색
|
|
- 카테고리 값이 많을 때 Combobox로 변경
|
|
- 자동완성 검색 기능
|
|
|
|
### 9.2 카테고리 템플릿
|
|
- 자주 사용하는 카테고리를 템플릿으로 저장
|
|
- 새 메뉴 생성 시 템플릿 적용
|
|
|
|
### 9.3 카테고리 분석
|
|
- 가장 많이 사용되는 카테고리 값 통계
|
|
- 사용되지 않는 카테고리 값 정리 제안
|
|
|
|
### 9.4 카테고리 권한 관리
|
|
- 특정 사용자만 특정 카테고리 값 선택 가능
|
|
- 카테고리 값별 접근 권한 설정
|
|
|
|
---
|
|
|
|
## 10. 요약
|
|
|
|
**카테고리 시스템**은 채번 규칙과 유사한 구조로 메뉴별로 독립적으로 관리되는 코드 시스템입니다.
|
|
|
|
**핵심 특징**:
|
|
- ✅ 메뉴별 독립 관리
|
|
- ✅ 계층 구조 지원 (최대 3단계)
|
|
- ✅ 멀티테넌시 격리
|
|
- ✅ 동적 추가/수정 가능
|
|
- ✅ 다중 선택 옵션
|
|
|
|
**공통코드와의 차이점**:
|
|
- 공통코드: 전사 공통 (모든 메뉴에서 동일)
|
|
- 카테고리: 메뉴별 독립 (각 화면마다 다른 값)
|
|
|
|
이제 이 계획서를 기반으로 구현을 시작하시겠습니까?
|
|
|