카테고리

This commit is contained in:
kjs 2025-11-05 15:24:05 +09:00
parent 573a300a4a
commit fe1c99c727
2 changed files with 171 additions and 105 deletions

View File

@ -148,7 +148,12 @@ class TableCategoryValueService {
AND (company_code = $4 OR company_code = '*') AND (company_code = $4 OR company_code = '*')
`; `;
const params: any[] = [tableName, columnName, siblingMenuIds, companyCode]; const params: any[] = [
tableName,
columnName,
siblingMenuIds,
companyCode,
];
if (!includeInactive) { if (!includeInactive) {
query += ` AND is_active = true`; query += ` AND is_active = true`;
@ -246,7 +251,7 @@ class TableCategoryValueService {
value.icon || null, value.icon || null,
value.isActive !== false, value.isActive !== false,
value.isDefault || false, value.isDefault || false,
value.menuId, // menuId 추가 value.menuId, // menuId 추가
companyCode, companyCode,
userId, userId,
]); ]);
@ -494,4 +499,3 @@ class TableCategoryValueService {
} }
export default new TableCategoryValueService(); export default new TableCategoryValueService();

View File

@ -12,6 +12,7 @@
**공통코드**와 유사하지만 **메뉴별로 독립적으로 관리**되는 코드 시스템입니다. **공통코드**와 유사하지만 **메뉴별로 독립적으로 관리**되는 코드 시스템입니다.
**주요 특징**: **주요 특징**:
- 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리 - 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리
- 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계) - 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계)
- 멀티테넌시: 회사별로 독립적으로 관리 - 멀티테넌시: 회사별로 독립적으로 관리
@ -20,16 +21,19 @@
### 1.2 사용 예시 ### 1.2 사용 예시
#### 프로젝트 관리 화면 #### 프로젝트 관리 화면
- 프로젝트 유형: 개발, 유지보수, 컨설팅 - 프로젝트 유형: 개발, 유지보수, 컨설팅
- 프로젝트 상태: 계획, 진행중, 완료, 보류 - 프로젝트 상태: 계획, 진행중, 완료, 보류
- 우선순위: 긴급, 높음, 보통, 낮음 - 우선순위: 긴급, 높음, 보통, 낮음
#### 계약관리 화면 #### 계약관리 화면
- 계약 유형: 판매, 구매, 임대, 용역 - 계약 유형: 판매, 구매, 임대, 용역
- 계약 상태: 작성중, 검토중, 체결, 종료 - 계약 상태: 작성중, 검토중, 체결, 종료
- 결제 방식: 현금, 카드, 계좌이체, 어음 - 결제 방식: 현금, 카드, 계좌이체, 어음
#### 자산관리 화면 #### 자산관리 화면
- 자산 분류: IT장비, 비품, 차량, 부동산 - 자산 분류: IT장비, 비품, 차량, 부동산
- 자산 상태: 정상, 수리중, 폐기, 분실 - 자산 상태: 정상, 수리중, 폐기, 분실
- 위치: 본사, 지점A, 지점B, 창고 - 위치: 본사, 지점A, 지점B, 창고
@ -47,23 +51,23 @@ CREATE TABLE IF NOT EXISTS menu_categories (
category_name VARCHAR(100) NOT NULL, -- 카테고리명 (예: 프로젝트 유형) category_name VARCHAR(100) NOT NULL, -- 카테고리명 (예: 프로젝트 유형)
menu_objid NUMERIC NOT NULL, -- 적용할 메뉴 OBJID menu_objid NUMERIC NOT NULL, -- 적용할 메뉴 OBJID
description TEXT, -- 설명 description TEXT, -- 설명
-- 설정 -- 설정
allow_custom_values BOOLEAN DEFAULT false, -- 사용자 정의 값 허용 여부 allow_custom_values BOOLEAN DEFAULT false, -- 사용자 정의 값 허용 여부
max_depth INTEGER DEFAULT 1, -- 최대 계층 깊이 (1~3) max_depth INTEGER DEFAULT 1, -- 최대 계층 깊이 (1~3)
is_multi_select BOOLEAN DEFAULT false, -- 다중 선택 가능 여부 is_multi_select BOOLEAN DEFAULT false, -- 다중 선택 가능 여부
-- 멀티테넌시 -- 멀티테넌시
company_code VARCHAR(20) NOT NULL, company_code VARCHAR(20) NOT NULL,
-- 메타 정보 -- 메타 정보
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50), created_by VARCHAR(50),
CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid) CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid)
REFERENCES menu_info(objid), REFERENCES menu_info(objid),
CONSTRAINT fk_category_company FOREIGN KEY (company_code) CONSTRAINT fk_category_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code) REFERENCES company_info(company_code)
); );
@ -83,27 +87,27 @@ CREATE TABLE IF NOT EXISTS category_values (
value_code VARCHAR(50) NOT NULL, -- 값 코드 (예: DEV, MAINT, CONSULT) value_code VARCHAR(50) NOT NULL, -- 값 코드 (예: DEV, MAINT, CONSULT)
value_label VARCHAR(100) NOT NULL, -- 값 라벨 (예: 개발, 유지보수, 컨설팅) value_label VARCHAR(100) NOT NULL, -- 값 라벨 (예: 개발, 유지보수, 컨설팅)
value_order INTEGER DEFAULT 0, -- 정렬 순서 value_order INTEGER DEFAULT 0, -- 정렬 순서
-- 계층 구조 -- 계층 구조
parent_value_id INTEGER, -- 상위 값 ID (NULL이면 최상위) parent_value_id INTEGER, -- 상위 값 ID (NULL이면 최상위)
depth INTEGER DEFAULT 1, -- 계층 깊이 (1~3) depth INTEGER DEFAULT 1, -- 계층 깊이 (1~3)
-- 추가 정보 -- 추가 정보
description TEXT, -- 설명 description TEXT, -- 설명
is_active BOOLEAN DEFAULT true, -- 활성화 여부 is_active BOOLEAN DEFAULT true, -- 활성화 여부
-- 멀티테넌시 -- 멀티테넌시
company_code VARCHAR(20) NOT NULL, company_code VARCHAR(20) NOT NULL,
-- 메타 정보 -- 메타 정보
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50), created_by VARCHAR(50),
CONSTRAINT fk_value_category FOREIGN KEY (category_id) CONSTRAINT fk_value_category FOREIGN KEY (category_id)
REFERENCES menu_categories(category_id) ON DELETE CASCADE, REFERENCES menu_categories(category_id) ON DELETE CASCADE,
CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id) CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id)
REFERENCES category_values(value_id), REFERENCES category_values(value_id),
CONSTRAINT fk_value_company FOREIGN KEY (company_code) CONSTRAINT fk_value_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code), REFERENCES company_info(company_code),
CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code) CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code)
); );
@ -119,32 +123,32 @@ CREATE INDEX idx_category_values_company ON category_values(company_code);
```sql ```sql
-- 샘플 카테고리: 프로젝트 유형 (메뉴 OBJID: 100) -- 샘플 카테고리: 프로젝트 유형 (메뉴 OBJID: 100)
INSERT INTO menu_categories ( INSERT INTO menu_categories (
category_id, category_name, menu_objid, description, category_id, category_name, menu_objid, description,
max_depth, company_code, created_by max_depth, company_code, created_by
) VALUES ( ) VALUES (
'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리', 'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리',
2, 'COMPANY_A', 'admin' 2, 'COMPANY_A', 'admin'
); );
-- 프로젝트 유형 값들 -- 프로젝트 유형 값들
INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by) INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by)
VALUES VALUES
('PROJ_TYPE', 'DEV', '개발', 1, 'COMPANY_A', 'admin'), ('PROJ_TYPE', 'DEV', '개발', 1, 'COMPANY_A', 'admin'),
('PROJ_TYPE', 'MAINT', '유지보수', 2, 'COMPANY_A', 'admin'), ('PROJ_TYPE', 'MAINT', '유지보수', 2, 'COMPANY_A', 'admin'),
('PROJ_TYPE', 'CONSULT', '컨설팅', 3, 'COMPANY_A', 'admin'); ('PROJ_TYPE', 'CONSULT', '컨설팅', 3, 'COMPANY_A', 'admin');
-- 샘플 카테고리: 프로젝트 상태 (메뉴 OBJID: 100) -- 샘플 카테고리: 프로젝트 상태 (메뉴 OBJID: 100)
INSERT INTO menu_categories ( INSERT INTO menu_categories (
category_id, category_name, menu_objid, description, category_id, category_name, menu_objid, description,
company_code, created_by company_code, created_by
) VALUES ( ) VALUES (
'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태', 'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태',
'COMPANY_A', 'admin' 'COMPANY_A', 'admin'
); );
-- 프로젝트 상태 값들 -- 프로젝트 상태 값들
INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by) INSERT INTO category_values (category_id, value_code, value_label, value_order, company_code, created_by)
VALUES VALUES
('PROJ_STATUS', 'PLAN', '계획', 1, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'PLAN', '계획', 1, 'COMPANY_A', 'admin'),
('PROJ_STATUS', 'PROGRESS', '진행중', 2, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'PROGRESS', '진행중', 2, 'COMPANY_A', 'admin'),
('PROJ_STATUS', 'COMPLETE', '완료', 3, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'COMPLETE', '완료', 3, 'COMPANY_A', 'admin'),
@ -152,16 +156,16 @@ VALUES
-- 샘플: 계층 구조 카테고리 (지역 → 도시 → 구) -- 샘플: 계층 구조 카테고리 (지역 → 도시 → 구)
INSERT INTO menu_categories ( INSERT INTO menu_categories (
category_id, category_name, menu_objid, description, category_id, category_name, menu_objid, description,
max_depth, company_code, created_by max_depth, company_code, created_by
) VALUES ( ) VALUES (
'REGION', '지역', 101, '지역/도시/구 계층 구조', 'REGION', '지역', 101, '지역/도시/구 계층 구조',
3, 'COMPANY_A', 'admin' 3, 'COMPANY_A', 'admin'
); );
-- 1단계: 지역 -- 1단계: 지역
INSERT INTO category_values (category_id, value_code, value_label, depth, value_order, company_code) INSERT INTO category_values (category_id, value_code, value_label, depth, value_order, company_code)
VALUES VALUES
('REGION', 'SEOUL', '서울특별시', 1, 1, 'COMPANY_A'), ('REGION', 'SEOUL', '서울특별시', 1, 1, 'COMPANY_A'),
('REGION', 'BUSAN', '부산광역시', 1, 2, 'COMPANY_A'); ('REGION', 'BUSAN', '부산광역시', 1, 2, 'COMPANY_A');
@ -189,15 +193,15 @@ export interface CategoryConfig {
categoryName: string; categoryName: string;
menuObjid: number; menuObjid: number;
description?: string; description?: string;
// 설정 // 설정
allowCustomValues?: boolean; allowCustomValues?: boolean;
maxDepth?: number; maxDepth?: number;
isMultiSelect?: boolean; isMultiSelect?: boolean;
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;
// 메타 // 메타
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@ -210,21 +214,21 @@ export interface CategoryValue {
valueCode: string; valueCode: string;
valueLabel: string; valueLabel: string;
valueOrder?: number; valueOrder?: number;
// 계층 // 계층
parentValueId?: number; parentValueId?: number;
depth?: number; depth?: number;
// 추가 정보 // 추가 정보
description?: string; description?: string;
isActive?: boolean; isActive?: boolean;
// 하위 항목 (조회 시) // 하위 항목 (조회 시)
children?: CategoryValue[]; children?: CategoryValue[];
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;
// 메타 // 메타
createdAt?: string; createdAt?: string;
createdBy?: string; createdBy?: string;
@ -546,9 +550,7 @@ class CategoryService {
const checkResult = await pool.query(checkQuery, [valueId, companyCode]); const checkResult = await pool.query(checkQuery, [valueId, companyCode]);
if (parseInt(checkResult.rows[0].count) > 0) { if (parseInt(checkResult.rows[0].count) > 0) {
throw new Error( throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다");
"하위 카테고리 값이 있어 삭제할 수 없습니다"
);
} }
// 비활성화 // 비활성화
@ -844,15 +846,15 @@ export interface CategoryConfig {
categoryName: string; categoryName: string;
menuObjid: number; menuObjid: number;
description?: string; description?: string;
// 설정 // 설정
allowCustomValues?: boolean; allowCustomValues?: boolean;
maxDepth?: number; maxDepth?: number;
isMultiSelect?: boolean; isMultiSelect?: boolean;
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;
// 메타 // 메타
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@ -865,21 +867,21 @@ export interface CategoryValue {
valueCode: string; valueCode: string;
valueLabel: string; valueLabel: string;
valueOrder?: number; valueOrder?: number;
// 계층 // 계층
parentValueId?: number; parentValueId?: number;
depth?: number; depth?: number;
// 추가 정보 // 추가 정보
description?: string; description?: string;
isActive?: boolean; isActive?: boolean;
// 하위 항목 // 하위 항목
children?: CategoryValue[]; children?: CategoryValue[];
// 멀티테넌시 // 멀티테넌시
companyCode?: string; companyCode?: string;
// 메타 // 메타
createdAt?: string; createdAt?: string;
createdBy?: string; createdBy?: string;
@ -899,9 +901,10 @@ import { CategoryConfig, CategoryValue } from "@/types/category";
*/ */
export async function getCategoriesByMenu(menuObjid: number) { export async function getCategoriesByMenu(menuObjid: number) {
try { try {
const response = await apiClient.get<{ success: boolean; data: CategoryConfig[] }>( const response = await apiClient.get<{
`/api/categories/menu/${menuObjid}` success: boolean;
); data: CategoryConfig[];
}>(`/api/categories/menu/${menuObjid}`);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 목록 조회 실패:", error); console.error("카테고리 목록 조회 실패:", error);
@ -914,9 +917,10 @@ export async function getCategoriesByMenu(menuObjid: number) {
*/ */
export async function getCategoryValues(categoryId: string) { export async function getCategoryValues(categoryId: string) {
try { try {
const response = await apiClient.get<{ success: boolean; data: CategoryValue[] }>( const response = await apiClient.get<{
`/api/categories/${categoryId}/values` success: boolean;
); data: CategoryValue[];
}>(`/api/categories/${categoryId}/values`);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 값 조회 실패:", error); console.error("카테고리 값 조회 실패:", error);
@ -929,10 +933,10 @@ export async function getCategoryValues(categoryId: string) {
*/ */
export async function createCategory(config: CategoryConfig) { export async function createCategory(config: CategoryConfig) {
try { try {
const response = await apiClient.post<{ success: boolean; data: CategoryConfig }>( const response = await apiClient.post<{
"/api/categories", success: boolean;
config data: CategoryConfig;
); }>("/api/categories", config);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 생성 실패:", error); console.error("카테고리 생성 실패:", error);
@ -945,10 +949,10 @@ export async function createCategory(config: CategoryConfig) {
*/ */
export async function addCategoryValue(value: CategoryValue) { export async function addCategoryValue(value: CategoryValue) {
try { try {
const response = await apiClient.post<{ success: boolean; data: CategoryValue }>( const response = await apiClient.post<{
"/api/categories/values", success: boolean;
value data: CategoryValue;
); }>("/api/categories/values", value);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 값 추가 실패:", error); console.error("카테고리 값 추가 실패:", error);
@ -959,12 +963,15 @@ export async function addCategoryValue(value: CategoryValue) {
/** /**
* 카테고리 값 수정 * 카테고리 값 수정
*/ */
export async function updateCategoryValue(valueId: number, updates: Partial<CategoryValue>) { export async function updateCategoryValue(
valueId: number,
updates: Partial<CategoryValue>
) {
try { try {
const response = await apiClient.put<{ success: boolean; data: CategoryValue }>( const response = await apiClient.put<{
`/api/categories/values/${valueId}`, success: boolean;
updates data: CategoryValue;
); }>(`/api/categories/values/${valueId}`, updates);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 값 수정 실패:", error); console.error("카테고리 값 수정 실패:", error);
@ -977,9 +984,10 @@ export async function updateCategoryValue(valueId: number, updates: Partial<Cate
*/ */
export async function deleteCategoryValue(valueId: number) { export async function deleteCategoryValue(valueId: number) {
try { try {
const response = await apiClient.delete<{ success: boolean; message: string }>( const response = await apiClient.delete<{
`/api/categories/values/${valueId}` success: boolean;
); message: string;
}>(`/api/categories/values/${valueId}`);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 값 삭제 실패:", error); console.error("카테고리 값 삭제 실패:", error);
@ -993,7 +1001,7 @@ export async function deleteCategoryValue(valueId: number) {
```typescript ```typescript
// frontend/types/screen.ts에 추가 // frontend/types/screen.ts에 추가
export type WebType = export type WebType =
| "text" | "text"
| "number" | "number"
| "decimal" | "decimal"
@ -1001,7 +1009,7 @@ export type WebType =
| "datetime" | "datetime"
| "select" | "select"
| "entity" | "entity"
| "category" // 신규 추가 | "category" // 신규 추가
| "textarea" | "textarea"
| "boolean" | "boolean"
| "checkbox" | "checkbox"
@ -1022,13 +1030,23 @@ export type WebType =
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Plus, X, ChevronRight } from "lucide-react"; import { Plus, X, ChevronRight } from "lucide-react";
import { getCategoriesByMenu, getCategoryValues, addCategoryValue } from "@/lib/api/category"; import {
getCategoriesByMenu,
getCategoryValues,
addCategoryValue,
} from "@/lib/api/category";
import { CategoryConfig, CategoryValue } from "@/types/category"; import { CategoryConfig, CategoryValue } from "@/types/category";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@ -1038,14 +1056,14 @@ interface CategoryTypeConfigPanelProps {
menuObjid?: number; menuObjid?: number;
} }
export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = ({ export const CategoryTypeConfigPanel: React.FC<
config, CategoryTypeConfigPanelProps
onUpdate, > = ({ config, onUpdate, menuObjid }) => {
menuObjid,
}) => {
const { toast } = useToast(); const { toast } = useToast();
const [categories, setCategories] = useState<CategoryConfig[]>([]); const [categories, setCategories] = useState<CategoryConfig[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>(config.categoryId || ""); const [selectedCategory, setSelectedCategory] = useState<string>(
config.categoryId || ""
);
const [categoryValues, setCategoryValues] = useState<CategoryValue[]>([]); const [categoryValues, setCategoryValues] = useState<CategoryValue[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -1161,12 +1179,16 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
</Badge> </Badge>
<span className="text-sm">{value.valueLabel}</span> <span className="text-sm">{value.valueLabel}</span>
</div> </div>
{value.children && value.children.length > 0 && renderCategoryValues(value.children, depth + 1)} {value.children &&
value.children.length > 0 &&
renderCategoryValues(value.children, depth + 1)}
</div> </div>
)); ));
}; };
const selectedCategoryConfig = categories.find((c) => c.categoryId === selectedCategory); const selectedCategoryConfig = categories.find(
(c) => c.categoryId === selectedCategory
);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -1179,7 +1201,11 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categories.map((cat) => ( {categories.map((cat) => (
<SelectItem key={cat.categoryId} value={cat.categoryId} className="text-xs sm:text-sm"> <SelectItem
key={cat.categoryId}
value={cat.categoryId}
className="text-xs sm:text-sm"
>
{cat.categoryName} {cat.categoryName}
</SelectItem> </SelectItem>
))} ))}
@ -1198,7 +1224,9 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
<Checkbox <Checkbox
id="multiSelect" id="multiSelect"
checked={config.isMultiSelect || false} checked={config.isMultiSelect || false}
onCheckedChange={(checked) => onUpdate({ isMultiSelect: checked as boolean })} onCheckedChange={(checked) =>
onUpdate({ isMultiSelect: checked as boolean })
}
/> />
<Label htmlFor="multiSelect" className="text-xs sm:text-sm"> <Label htmlFor="multiSelect" className="text-xs sm:text-sm">
다중 선택 허용 다중 선택 허용
@ -1209,7 +1237,9 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
{/* 카테고리 값 목록 */} {/* 카테고리 값 목록 */}
{selectedCategory && categoryValues.length > 0 && ( {selectedCategory && categoryValues.length > 0 && (
<div> <div>
<Label className="text-xs font-medium sm:text-sm">카테고리 값 목록</Label> <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"> <div className="mt-2 max-h-64 overflow-y-auto rounded-md border p-2">
{renderCategoryValues(categoryValues)} {renderCategoryValues(categoryValues)}
</div> </div>
@ -1220,15 +1250,26 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
{selectedCategory && selectedCategoryConfig?.allowCustomValues && ( {selectedCategory && selectedCategoryConfig?.allowCustomValues && (
<div> <div>
{!isAddingValue ? ( {!isAddingValue ? (
<Button variant="outline" size="sm" onClick={() => setIsAddingValue(true)} className="w-full"> <Button
<Plus className="mr-2 h-4 w-4" /> variant="outline"
새 값 추가 size="sm"
onClick={() => setIsAddingValue(true)}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />새 값 추가
</Button> </Button>
) : ( ) : (
<div className="space-y-2 rounded-md border p-3"> <div className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-medium sm:text-sm">새 카테고리 값</Label> <Label className="text-xs font-medium sm:text-sm">
<Button variant="ghost" size="icon" onClick={() => setIsAddingValue(false)} className="h-6 w-6"> 새 카테고리 값
</Label>
<Button
variant="ghost"
size="icon"
onClick={() => setIsAddingValue(false)}
className="h-6 w-6"
>
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</div> </div>
@ -1256,8 +1297,17 @@ export const CategoryTypeConfigPanel: React.FC<CategoryTypeConfigPanelProps> = (
{selectedCategoryConfig && ( {selectedCategoryConfig && (
<div className="rounded-md bg-muted p-3 text-xs"> <div className="rounded-md bg-muted p-3 text-xs">
<div className="flex gap-2"> <div className="flex gap-2">
<Badge variant={selectedCategoryConfig.allowCustomValues ? "default" : "secondary"} className="text-[10px]"> <Badge
{selectedCategoryConfig.allowCustomValues ? "사용자 정의 허용" : "고정 값"} variant={
selectedCategoryConfig.allowCustomValues
? "default"
: "secondary"
}
className="text-[10px]"
>
{selectedCategoryConfig.allowCustomValues
? "사용자 정의 허용"
: "고정 값"}
</Badge> </Badge>
<Badge variant="outline" className="text-[10px]"> <Badge variant="outline" className="text-[10px]">
최대 깊이: {selectedCategoryConfig.maxDepth}단계 최대 깊이: {selectedCategoryConfig.maxDepth}단계
@ -1348,12 +1398,14 @@ const renderCategoryOptions = (values: CategoryValue[], depth: number = 0) => {
### 시나리오 1: 프로젝트 관리 화면에서 카테고리 사용 ### 시나리오 1: 프로젝트 관리 화면에서 카테고리 사용
1. **관리자가 카테고리 생성** 1. **관리자가 카테고리 생성**
- 화면관리에서 "프로젝트 관리" 화면 선택 - 화면관리에서 "프로젝트 관리" 화면 선택
- 카테고리 관리 메뉴로 이동 - 카테고리 관리 메뉴로 이동
- "프로젝트 유형" 카테고리 생성 - "프로젝트 유형" 카테고리 생성
- 값 추가: 개발, 유지보수, 컨설팅 - 값 추가: 개발, 유지보수, 컨설팅
2. **화면에 카테고리 컴포넌트 배치** 2. **화면에 카테고리 컴포넌트 배치**
- 위젯 추가 → 웹타입: "category" 선택 - 위젯 추가 → 웹타입: "category" 선택
- 카테고리 선택: "프로젝트 유형" - 카테고리 선택: "프로젝트 유형"
- 라벨 설정: "프로젝트 유형" - 라벨 설정: "프로젝트 유형"
@ -1366,6 +1418,7 @@ const renderCategoryOptions = (values: CategoryValue[], depth: number = 0) => {
### 시나리오 2: 계층 구조 카테고리 ### 시나리오 2: 계층 구조 카테고리
1. **관리자가 3단계 카테고리 생성** 1. **관리자가 3단계 카테고리 생성**
- "지역" 카테고리 생성 (maxDepth: 3) - "지역" 카테고리 생성 (maxDepth: 3)
- 1단계: 서울특별시, 부산광역시 - 1단계: 서울특별시, 부산광역시
- 2단계: 강남구, 송파구 (서울 하위) - 2단계: 강남구, 송파구 (서울 하위)
@ -1388,20 +1441,20 @@ CREATE TABLE IF NOT EXISTS menu_categories (
category_name VARCHAR(100) NOT NULL, category_name VARCHAR(100) NOT NULL,
menu_objid NUMERIC NOT NULL, menu_objid NUMERIC NOT NULL,
description TEXT, description TEXT,
allow_custom_values BOOLEAN DEFAULT false, allow_custom_values BOOLEAN DEFAULT false,
max_depth INTEGER DEFAULT 1, max_depth INTEGER DEFAULT 1,
is_multi_select BOOLEAN DEFAULT false, is_multi_select BOOLEAN DEFAULT false,
company_code VARCHAR(20) NOT NULL, company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50), created_by VARCHAR(50),
CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid) CONSTRAINT fk_category_menu FOREIGN KEY (menu_objid)
REFERENCES menu_info(objid), REFERENCES menu_info(objid),
CONSTRAINT fk_category_company FOREIGN KEY (company_code) CONSTRAINT fk_category_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code) REFERENCES company_info(company_code)
); );
@ -1412,23 +1465,23 @@ CREATE TABLE IF NOT EXISTS category_values (
value_code VARCHAR(50) NOT NULL, value_code VARCHAR(50) NOT NULL,
value_label VARCHAR(100) NOT NULL, value_label VARCHAR(100) NOT NULL,
value_order INTEGER DEFAULT 0, value_order INTEGER DEFAULT 0,
parent_value_id INTEGER, parent_value_id INTEGER,
depth INTEGER DEFAULT 1, depth INTEGER DEFAULT 1,
description TEXT, description TEXT,
is_active BOOLEAN DEFAULT true, is_active BOOLEAN DEFAULT true,
company_code VARCHAR(20) NOT NULL, company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(), created_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50), created_by VARCHAR(50),
CONSTRAINT fk_value_category FOREIGN KEY (category_id) CONSTRAINT fk_value_category FOREIGN KEY (category_id)
REFERENCES menu_categories(category_id) ON DELETE CASCADE, REFERENCES menu_categories(category_id) ON DELETE CASCADE,
CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id) CONSTRAINT fk_value_parent FOREIGN KEY (parent_value_id)
REFERENCES category_values(value_id), REFERENCES category_values(value_id),
CONSTRAINT fk_value_company FOREIGN KEY (company_code) CONSTRAINT fk_value_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code), REFERENCES company_info(company_code),
CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code) CONSTRAINT unique_category_code UNIQUE (category_id, value_code, company_code)
); );
@ -1457,12 +1510,14 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
## 8. 구현 체크리스트 ## 8. 구현 체크리스트
### 데이터베이스 ✅ ### 데이터베이스 ✅
- [ ] 마이그레이션 파일 작성 (036_create_menu_categories.sql) - [ ] 마이그레이션 파일 작성 (036_create_menu_categories.sql)
- [ ] 테이블 생성 및 인덱스 - [ ] 테이블 생성 및 인덱스
- [ ] 샘플 데이터 삽입 - [ ] 샘플 데이터 삽입
- [ ] 외래키 제약조건 설정 - [ ] 외래키 제약조건 설정
### 백엔드 ✅ ### 백엔드 ✅
- [ ] 타입 정의 (backend-node/src/types/category.ts) - [ ] 타입 정의 (backend-node/src/types/category.ts)
- [ ] 서비스 레이어 (categoryService.ts) - [ ] 서비스 레이어 (categoryService.ts)
- [ ] 컨트롤러 레이어 (categoryController.ts) - [ ] 컨트롤러 레이어 (categoryController.ts)
@ -1470,6 +1525,7 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
- [ ] app.ts에 라우트 등록 - [ ] app.ts에 라우트 등록
### 프론트엔드 ✅ ### 프론트엔드 ✅
- [ ] 타입 정의 (frontend/types/category.ts) - [ ] 타입 정의 (frontend/types/category.ts)
- [ ] API 클라이언트 (frontend/lib/api/category.ts) - [ ] API 클라이언트 (frontend/lib/api/category.ts)
- [ ] WebType에 "category" 추가 - [ ] WebType에 "category" 추가
@ -1478,6 +1534,7 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
- [ ] RealtimePreview에 렌더링 로직 추가 - [ ] RealtimePreview에 렌더링 로직 추가
### 테스트 ✅ ### 테스트 ✅
- [ ] 카테고리 생성/조회/수정/삭제 API 테스트 - [ ] 카테고리 생성/조회/수정/삭제 API 테스트
- [ ] 메뉴별 카테고리 필터링 테스트 - [ ] 메뉴별 카테고리 필터링 테스트
- [ ] 계층 구조 렌더링 테스트 - [ ] 계층 구조 렌더링 테스트
@ -1488,18 +1545,22 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
## 9. 향후 확장 가능성 ## 9. 향후 확장 가능성
### 9.1 동적 카테고리 검색 ### 9.1 동적 카테고리 검색
- 카테고리 값이 많을 때 Combobox로 변경 - 카테고리 값이 많을 때 Combobox로 변경
- 자동완성 검색 기능 - 자동완성 검색 기능
### 9.2 카테고리 템플릿 ### 9.2 카테고리 템플릿
- 자주 사용하는 카테고리를 템플릿으로 저장 - 자주 사용하는 카테고리를 템플릿으로 저장
- 새 메뉴 생성 시 템플릿 적용 - 새 메뉴 생성 시 템플릿 적용
### 9.3 카테고리 분석 ### 9.3 카테고리 분석
- 가장 많이 사용되는 카테고리 값 통계 - 가장 많이 사용되는 카테고리 값 통계
- 사용되지 않는 카테고리 값 정리 제안 - 사용되지 않는 카테고리 값 정리 제안
### 9.4 카테고리 권한 관리 ### 9.4 카테고리 권한 관리
- 특정 사용자만 특정 카테고리 값 선택 가능 - 특정 사용자만 특정 카테고리 값 선택 가능
- 카테고리 값별 접근 권한 설정 - 카테고리 값별 접근 권한 설정
@ -1510,6 +1571,7 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
**카테고리 시스템**은 채번 규칙과 유사한 구조로 메뉴별로 독립적으로 관리되는 코드 시스템입니다. **카테고리 시스템**은 채번 규칙과 유사한 구조로 메뉴별로 독립적으로 관리되는 코드 시스템입니다.
**핵심 특징**: **핵심 특징**:
- ✅ 메뉴별 독립 관리 - ✅ 메뉴별 독립 관리
- ✅ 계층 구조 지원 (최대 3단계) - ✅ 계층 구조 지원 (최대 3단계)
- ✅ 멀티테넌시 격리 - ✅ 멀티테넌시 격리
@ -1517,8 +1579,8 @@ SELECT 'Migration 036: Menu Categories created successfully!' AS status;
- ✅ 다중 선택 옵션 - ✅ 다중 선택 옵션
**공통코드와의 차이점**: **공통코드와의 차이점**:
- 공통코드: 전사 공통 (모든 메뉴에서 동일) - 공통코드: 전사 공통 (모든 메뉴에서 동일)
- 카테고리: 메뉴별 독립 (각 화면마다 다른 값) - 카테고리: 메뉴별 독립 (각 화면마다 다른 값)
이제 이 계획서를 기반으로 구현을 시작하시겠습니까? 이제 이 계획서를 기반으로 구현을 시작하시겠습니까?