From fe1c99c727e3717a88fb21593f1d52c991d2f700 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 5 Nov 2025 15:24:05 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/tableCategoryValueService.ts | 10 +- 카테고리_시스템_구현_계획서.md | 266 +++++++++++------- 2 files changed, 171 insertions(+), 105 deletions(-) diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index a459e24b..81be6361 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -148,7 +148,12 @@ class TableCategoryValueService { AND (company_code = $4 OR company_code = '*') `; - const params: any[] = [tableName, columnName, siblingMenuIds, companyCode]; + const params: any[] = [ + tableName, + columnName, + siblingMenuIds, + companyCode, + ]; if (!includeInactive) { query += ` AND is_active = true`; @@ -246,7 +251,7 @@ class TableCategoryValueService { value.icon || null, value.isActive !== false, value.isDefault || false, - value.menuId, // menuId 추가 + value.menuId, // menuId 추가 companyCode, userId, ]); @@ -494,4 +499,3 @@ class TableCategoryValueService { } export default new TableCategoryValueService(); - diff --git a/카테고리_시스템_구현_계획서.md b/카테고리_시스템_구현_계획서.md index e8df0694..e5a08c9f 100644 --- a/카테고리_시스템_구현_계획서.md +++ b/카테고리_시스템_구현_계획서.md @@ -12,6 +12,7 @@ **공통코드**와 유사하지만 **메뉴별로 독립적으로 관리**되는 코드 시스템입니다. **주요 특징**: + - 메뉴별 독립 관리: 각 화면(메뉴)마다 별도의 카테고리 목록 관리 - 계층 구조 지원: 상위 카테고리 → 하위 카테고리 (최대 3단계) - 멀티테넌시: 회사별로 독립적으로 관리 @@ -20,16 +21,19 @@ ### 1.2 사용 예시 #### 프로젝트 관리 화면 + - 프로젝트 유형: 개발, 유지보수, 컨설팅 - 프로젝트 상태: 계획, 진행중, 완료, 보류 - 우선순위: 긴급, 높음, 보통, 낮음 -#### 계약관리 화면 +#### 계약관리 화면 + - 계약 유형: 판매, 구매, 임대, 용역 - 계약 상태: 작성중, 검토중, 체결, 종료 - 결제 방식: 현금, 카드, 계좌이체, 어음 #### 자산관리 화면 + - 자산 분류: IT장비, 비품, 차량, 부동산 - 자산 상태: 정상, 수리중, 폐기, 분실 - 위치: 본사, 지점A, 지점B, 창고 @@ -47,23 +51,23 @@ CREATE TABLE IF NOT EXISTS menu_categories ( 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) + + CONSTRAINT fk_category_menu FOREIGN KEY (menu_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) ); @@ -83,27 +87,27 @@ CREATE TABLE IF NOT EXISTS category_values ( 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) + + 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) + CONSTRAINT fk_value_parent FOREIGN KEY (parent_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), 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 -- 샘플 카테고리: 프로젝트 유형 (메뉴 OBJID: 100) INSERT INTO menu_categories ( - category_id, category_name, menu_objid, description, + category_id, category_name, menu_objid, description, max_depth, company_code, created_by ) VALUES ( - 'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리', + 'PROJ_TYPE', '프로젝트 유형', 100, '프로젝트 분류를 위한 카테고리', 2, 'COMPANY_A', 'admin' ); -- 프로젝트 유형 값들 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', '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, + category_id, category_name, menu_objid, description, company_code, created_by ) VALUES ( - 'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태', + 'PROJ_STATUS', '프로젝트 상태', 100, '프로젝트 진행 상태', 'COMPANY_A', 'admin' ); -- 프로젝트 상태 값들 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', 'PROGRESS', '진행중', 2, 'COMPANY_A', 'admin'), ('PROJ_STATUS', 'COMPLETE', '완료', 3, 'COMPANY_A', 'admin'), @@ -152,16 +156,16 @@ VALUES -- 샘플: 계층 구조 카테고리 (지역 → 도시 → 구) INSERT INTO menu_categories ( - category_id, category_name, menu_objid, description, + category_id, category_name, menu_objid, description, max_depth, company_code, created_by ) VALUES ( - 'REGION', '지역', 101, '지역/도시/구 계층 구조', + 'REGION', '지역', 101, '지역/도시/구 계층 구조', 3, 'COMPANY_A', 'admin' ); -- 1단계: 지역 INSERT INTO category_values (category_id, value_code, value_label, depth, value_order, company_code) -VALUES +VALUES ('REGION', 'SEOUL', '서울특별시', 1, 1, 'COMPANY_A'), ('REGION', 'BUSAN', '부산광역시', 1, 2, 'COMPANY_A'); @@ -189,15 +193,15 @@ export interface CategoryConfig { categoryName: string; menuObjid: number; description?: string; - + // 설정 allowCustomValues?: boolean; maxDepth?: number; isMultiSelect?: boolean; - + // 멀티테넌시 companyCode?: string; - + // 메타 createdAt?: string; updatedAt?: string; @@ -210,21 +214,21 @@ export interface CategoryValue { valueCode: string; valueLabel: string; valueOrder?: number; - + // 계층 parentValueId?: number; depth?: number; - + // 추가 정보 description?: string; isActive?: boolean; - + // 하위 항목 (조회 시) children?: CategoryValue[]; - + // 멀티테넌시 companyCode?: string; - + // 메타 createdAt?: string; createdBy?: string; @@ -546,9 +550,7 @@ class CategoryService { const checkResult = await pool.query(checkQuery, [valueId, companyCode]); if (parseInt(checkResult.rows[0].count) > 0) { - throw new Error( - "하위 카테고리 값이 있어 삭제할 수 없습니다" - ); + throw new Error("하위 카테고리 값이 있어 삭제할 수 없습니다"); } // 비활성화 @@ -844,15 +846,15 @@ export interface CategoryConfig { categoryName: string; menuObjid: number; description?: string; - + // 설정 allowCustomValues?: boolean; maxDepth?: number; isMultiSelect?: boolean; - + // 멀티테넌시 companyCode?: string; - + // 메타 createdAt?: string; updatedAt?: string; @@ -865,21 +867,21 @@ export interface CategoryValue { valueCode: string; valueLabel: string; valueOrder?: number; - + // 계층 parentValueId?: number; depth?: number; - + // 추가 정보 description?: string; isActive?: boolean; - + // 하위 항목 children?: CategoryValue[]; - + // 멀티테넌시 companyCode?: string; - + // 메타 createdAt?: string; createdBy?: string; @@ -899,9 +901,10 @@ 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}` - ); + const response = await apiClient.get<{ + success: boolean; + data: CategoryConfig[]; + }>(`/api/categories/menu/${menuObjid}`); return response.data; } catch (error: any) { console.error("카테고리 목록 조회 실패:", error); @@ -914,9 +917,10 @@ export async function getCategoriesByMenu(menuObjid: number) { */ export async function getCategoryValues(categoryId: string) { try { - const response = await apiClient.get<{ success: boolean; data: CategoryValue[] }>( - `/api/categories/${categoryId}/values` - ); + const response = await apiClient.get<{ + success: boolean; + data: CategoryValue[]; + }>(`/api/categories/${categoryId}/values`); return response.data; } catch (error: any) { console.error("카테고리 값 조회 실패:", error); @@ -929,10 +933,10 @@ export async function getCategoryValues(categoryId: string) { */ export async function createCategory(config: CategoryConfig) { try { - const response = await apiClient.post<{ success: boolean; data: CategoryConfig }>( - "/api/categories", - config - ); + const response = await apiClient.post<{ + success: boolean; + data: CategoryConfig; + }>("/api/categories", config); return response.data; } catch (error: any) { console.error("카테고리 생성 실패:", error); @@ -945,10 +949,10 @@ export async function createCategory(config: CategoryConfig) { */ export async function addCategoryValue(value: CategoryValue) { try { - const response = await apiClient.post<{ success: boolean; data: CategoryValue }>( - "/api/categories/values", - value - ); + const response = await apiClient.post<{ + success: boolean; + data: CategoryValue; + }>("/api/categories/values", value); return response.data; } catch (error: any) { console.error("카테고리 값 추가 실패:", error); @@ -959,12 +963,15 @@ export async function addCategoryValue(value: CategoryValue) { /** * 카테고리 값 수정 */ -export async function updateCategoryValue(valueId: number, updates: Partial) { +export async function updateCategoryValue( + valueId: number, + updates: Partial +) { try { - const response = await apiClient.put<{ success: boolean; data: CategoryValue }>( - `/api/categories/values/${valueId}`, - updates - ); + const response = await apiClient.put<{ + success: boolean; + data: CategoryValue; + }>(`/api/categories/values/${valueId}`, updates); return response.data; } catch (error: any) { console.error("카테고리 값 수정 실패:", error); @@ -977,9 +984,10 @@ export async function updateCategoryValue(valueId: number, updates: Partial( - `/api/categories/values/${valueId}` - ); + const response = await apiClient.delete<{ + success: boolean; + message: string; + }>(`/api/categories/values/${valueId}`); return response.data; } catch (error: any) { console.error("카테고리 값 삭제 실패:", error); @@ -993,7 +1001,7 @@ export async function deleteCategoryValue(valueId: number) { ```typescript // frontend/types/screen.ts에 추가 -export type WebType = +export type WebType = | "text" | "number" | "decimal" @@ -1001,7 +1009,7 @@ export type WebType = | "datetime" | "select" | "entity" - | "category" // 신규 추가 + | "category" // 신규 추가 | "textarea" | "boolean" | "checkbox" @@ -1022,13 +1030,23 @@ export type WebType = import React, { useState, useEffect } from "react"; 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 { 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 { + getCategoriesByMenu, + getCategoryValues, + addCategoryValue, +} from "@/lib/api/category"; import { CategoryConfig, CategoryValue } from "@/types/category"; import { useToast } from "@/hooks/use-toast"; @@ -1038,14 +1056,14 @@ interface CategoryTypeConfigPanelProps { menuObjid?: number; } -export const CategoryTypeConfigPanel: React.FC = ({ - config, - onUpdate, - menuObjid, -}) => { +export const CategoryTypeConfigPanel: React.FC< + CategoryTypeConfigPanelProps +> = ({ config, onUpdate, menuObjid }) => { const { toast } = useToast(); const [categories, setCategories] = useState([]); - const [selectedCategory, setSelectedCategory] = useState(config.categoryId || ""); + const [selectedCategory, setSelectedCategory] = useState( + config.categoryId || "" + ); const [categoryValues, setCategoryValues] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -1161,12 +1179,16 @@ export const CategoryTypeConfigPanel: React.FC = ( {value.valueLabel} - {value.children && value.children.length > 0 && renderCategoryValues(value.children, depth + 1)} + {value.children && + value.children.length > 0 && + renderCategoryValues(value.children, depth + 1)} )); }; - const selectedCategoryConfig = categories.find((c) => c.categoryId === selectedCategory); + const selectedCategoryConfig = categories.find( + (c) => c.categoryId === selectedCategory + ); return (
@@ -1179,7 +1201,11 @@ export const CategoryTypeConfigPanel: React.FC = ( {categories.map((cat) => ( - + {cat.categoryName} ))} @@ -1198,7 +1224,9 @@ export const CategoryTypeConfigPanel: React.FC = ( onUpdate({ isMultiSelect: checked as boolean })} + onCheckedChange={(checked) => + onUpdate({ isMultiSelect: checked as boolean }) + } />