From 668b45d4eac4af7637b23545aa2d51255a2ab93b Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 11 Nov 2025 14:32:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B1=84=EB=B2=88=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=8A=A4=EC=BD=94=ED=94=84=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ 주요 변경사항: - 백엔드: menuService.ts 추가 (형제 메뉴 조회 유틸리티) - 백엔드: numberingRuleService.getAvailableRulesForMenu() 메뉴 스코프 적용 - 백엔드: tableCategoryValueService 메뉴 스코프 준비 (menuObjid 파라미터 추가) - 프론트엔드: TextInputConfigPanel에 부모 메뉴 선택 UI 추가 - 프론트엔드: 메뉴별 채번규칙 필터링 (형제 메뉴 공유) 🔧 기술 세부사항: - getSiblingMenuObjids(): 같은 부모를 가진 형제 메뉴 OBJID 조회 - 채번규칙 우선순위: menu (형제) > table > global - 사용자 메뉴(menu_type='1') 레벨 2만 부모 메뉴로 선택 가능 📝 다음 단계: - 카테고리 컴포넌트도 메뉴 스코프로 전환 예정 --- .../controllers/numberingRuleController.ts | 14 +- .../tableCategoryValueController.ts | 40 +- backend-node/src/services/menuService.ts | 159 +++ .../src/services/numberingRuleService.ts | 174 +-- .../src/services/tableCategoryValueService.ts | 228 ++-- .../app/(main)/screens/[screenId]/page.tsx | 7 +- .../numbering-rule/NumberingRuleDesigner.tsx | 16 +- .../screen/RealtimePreviewDynamic.tsx | 3 + .../webtype-configs/TextTypeConfigPanel.tsx | 188 ++- .../screen/widgets/CategoryWidget.tsx | 7 +- .../table-category/CategoryColumnList.tsx | 7 +- .../table-category/CategoryValueManager.tsx | 28 +- frontend/lib/api/tableCategoryValue.ts | 32 +- .../lib/registry/DynamicComponentRenderer.tsx | 3 + .../text-input/TextInputConfigPanel.tsx | 196 +++- 카테고리_채번_메뉴스코프_전환_통합_계획서.md | 1004 +++++++++++++++++ 16 files changed, 1838 insertions(+), 268 deletions(-) create mode 100644 backend-node/src/services/menuService.ts create mode 100644 카테고리_채번_메뉴스코프_전환_통합_계획서.md diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index 556d09df..1b2e2197 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -27,12 +27,24 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate const companyCode = req.user!.companyCode; const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; + logger.info("📥 메뉴별 채번 규칙 조회 요청", { companyCode, menuObjid }); + try { const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); + + logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", { + companyCode, + menuObjid, + rulesCount: rules.length + }); + return res.json({ success: true, data: rules }); } catch (error: any) { - logger.error("메뉴별 사용 가능한 규칙 조회 실패", { + logger.error("❌ 메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", { error: error.message, + errorCode: error.code, + errorStack: error.stack, + companyCode, menuObjid, }); return res.status(500).json({ success: false, error: error.message }); diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index 865f7672..ffb6a5a4 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -32,18 +32,31 @@ export const getCategoryColumns = async (req: AuthenticatedRequest, res: Respons /** * 카테고리 값 목록 조회 (메뉴 스코프 적용) + * + * Query Parameters: + * - menuObjid: 메뉴 OBJID (선택사항, 제공 시 형제 메뉴의 카테고리 값 포함) + * - includeInactive: 비활성 값 포함 여부 */ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user!.companyCode; const { tableName, columnName } = req.params; const includeInactive = req.query.includeInactive === "true"; + const menuObjid = req.query.menuObjid ? Number(req.query.menuObjid) : undefined; + + logger.info("카테고리 값 조회 요청", { + tableName, + columnName, + menuObjid, + companyCode, + }); const values = await tableCategoryValueService.getCategoryValues( tableName, columnName, companyCode, - includeInactive + includeInactive, + menuObjid // ← menuObjid 전달 ); return res.json({ @@ -61,18 +74,37 @@ export const getCategoryValues = async (req: AuthenticatedRequest, res: Response }; /** - * 카테고리 값 추가 + * 카테고리 값 추가 (메뉴 스코프) + * + * Body: + * - menuObjid: 메뉴 OBJID (필수) + * - 나머지 카테고리 값 정보 */ export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => { try { const companyCode = req.user!.companyCode; const userId = req.user!.userId; - const value = req.body; + const { menuObjid, ...value } = req.body; + + if (!menuObjid) { + return res.status(400).json({ + success: false, + message: "menuObjid는 필수입니다", + }); + } + + logger.info("카테고리 값 추가 요청", { + tableName: value.tableName, + columnName: value.columnName, + menuObjid, + companyCode, + }); const newValue = await tableCategoryValueService.addCategoryValue( value, companyCode, - userId + userId, + Number(menuObjid) // ← menuObjid 전달 ); return res.status(201).json({ diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts new file mode 100644 index 00000000..9a9be99c --- /dev/null +++ b/backend-node/src/services/menuService.ts @@ -0,0 +1,159 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 메뉴 관련 유틸리티 서비스 + * + * 메뉴 스코프 기반 데이터 공유를 위한 형제 메뉴 조회 기능 제공 + */ + +/** + * 메뉴의 형제 메뉴 OBJID 목록 조회 + * (같은 부모를 가진 메뉴들) + * + * 메뉴 스코프 규칙: + * - 같은 부모를 가진 형제 메뉴들은 카테고리/채번규칙을 공유 + * - 최상위 메뉴(parent_obj_id = 0)는 자기 자신만 반환 + * - 메뉴를 찾을 수 없으면 안전하게 자기 자신만 반환 + * + * @param menuObjid 현재 메뉴의 OBJID + * @returns 형제 메뉴 OBJID 배열 (자기 자신 포함, 정렬됨) + * + * @example + * // 영업관리 (200) + * // ├── 고객관리 (201) + * // ├── 계약관리 (202) + * // └── 주문관리 (203) + * + * await getSiblingMenuObjids(201); + * // 결과: [201, 202, 203] - 모두 같은 부모(200)를 가진 형제 + */ +export async function getSiblingMenuObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.info("형제 메뉴 조회 시작", { menuObjid }); + + // 1. 현재 메뉴의 부모 찾기 + const parentQuery = ` + SELECT parent_obj_id FROM menu_info WHERE objid = $1 + `; + const parentResult = await pool.query(parentQuery, [menuObjid]); + + if (parentResult.rows.length === 0) { + logger.warn("메뉴를 찾을 수 없음, 자기 자신만 반환", { menuObjid }); + return [menuObjid]; // 메뉴가 없으면 안전하게 자기 자신만 반환 + } + + const parentObjId = parentResult.rows[0].parent_obj_id; + + if (!parentObjId || parentObjId === 0) { + // 최상위 메뉴인 경우 자기 자신만 반환 + logger.info("최상위 메뉴 (형제 없음)", { menuObjid, parentObjId }); + return [menuObjid]; + } + + // 2. 같은 부모를 가진 형제 메뉴들 조회 + const siblingsQuery = ` + SELECT objid FROM menu_info + WHERE parent_obj_id = $1 + ORDER BY objid + `; + const siblingsResult = await pool.query(siblingsQuery, [parentObjId]); + + const siblingObjids = siblingsResult.rows.map((row) => Number(row.objid)); + + logger.info("형제 메뉴 조회 완료", { + menuObjid, + parentObjId, + siblingCount: siblingObjids.length, + siblings: siblingObjids, + }); + + return siblingObjids; + } catch (error: any) { + logger.error("형제 메뉴 조회 실패", { + menuObjid, + error: error.message, + stack: error.stack + }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + +/** + * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 + * + * 여러 메뉴에 속한 모든 형제 메뉴를 중복 제거하여 반환 + * + * @param menuObjids 메뉴 OBJID 배열 + * @returns 모든 형제 메뉴 OBJID 배열 (중복 제거, 정렬됨) + * + * @example + * // 서로 다른 부모를 가진 메뉴들의 형제를 모두 조회 + * await getAllSiblingMenuObjids([201, 301]); + * // 201의 형제: [201, 202, 203] + * // 301의 형제: [301, 302] + * // 결과: [201, 202, 203, 301, 302] + */ +export async function getAllSiblingMenuObjids( + menuObjids: number[] +): Promise { + if (!menuObjids || menuObjids.length === 0) { + logger.warn("getAllSiblingMenuObjids: 빈 배열 입력"); + return []; + } + + const allSiblings = new Set(); + + for (const objid of menuObjids) { + const siblings = await getSiblingMenuObjids(objid); + siblings.forEach((s) => allSiblings.add(s)); + } + + const result = Array.from(allSiblings).sort((a, b) => a - b); + + logger.info("여러 메뉴의 형제 조회 완료", { + inputMenus: menuObjids, + resultCount: result.length, + result, + }); + + return result; +} + +/** + * 메뉴 정보 조회 + * + * @param menuObjid 메뉴 OBJID + * @returns 메뉴 정보 (없으면 null) + */ +export async function getMenuInfo(menuObjid: number): Promise { + const pool = getPool(); + + try { + const query = ` + SELECT + objid, + parent_obj_id AS "parentObjId", + menu_name_kor AS "menuNameKor", + menu_name_eng AS "menuNameEng", + menu_url AS "menuUrl", + company_code AS "companyCode" + FROM menu_info + WHERE objid = $1 + `; + const result = await pool.query(query, [menuObjid]); + + if (result.rows.length === 0) { + return null; + } + + return result.rows[0]; + } catch (error: any) { + logger.error("메뉴 정보 조회 실패", { menuObjid, error: error.message }); + return null; + } +} + diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index 98230b65..2c89f188 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,6 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { getSiblingMenuObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -150,22 +151,33 @@ class NumberingRuleService { } /** - * 현재 메뉴에서 사용 가능한 규칙 목록 조회 + * 현재 메뉴에서 사용 가능한 규칙 목록 조회 (메뉴 스코프) + * + * 메뉴 스코프 규칙: + * - menuObjid가 제공되면 형제 메뉴의 채번 규칙 포함 + * - 우선순위: menu (형제 메뉴) > table > global */ async getAvailableRulesForMenu( companyCode: string, menuObjid?: number ): Promise { try { - logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", { + logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { companyCode, menuObjid, }); const pool = getPool(); + // 1. 형제 메뉴 OBJID 조회 + let siblingObjids: number[] = []; + if (menuObjid) { + siblingObjids = await getSiblingMenuObjids(menuObjid); + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + } + // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid) { + if (!menuObjid || siblingObjids.length === 0) { let query: string; let params: any[]; @@ -261,35 +273,13 @@ class NumberingRuleService { return result.rows; } - // 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기) - const menuHierarchyQuery = ` - WITH RECURSIVE menu_path AS ( - SELECT objid, objid_parent, menu_level - FROM menu_info - WHERE objid = $1 - - UNION ALL - - SELECT mi.objid, mi.objid_parent, mi.menu_level - FROM menu_info mi - INNER JOIN menu_path mp ON mi.objid = mp.objid_parent - ) - SELECT objid, menu_level - FROM menu_path - WHERE menu_level = 2 - LIMIT 1 - `; - - const hierarchyResult = await pool.query(menuHierarchyQuery, [menuObjid]); - const level2MenuObjid = - hierarchyResult.rowCount > 0 ? hierarchyResult.rows[0].objid : null; - - // 사용 가능한 규칙 조회 (멀티테넌시 적용) + // 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회 + // 우선순위: menu (형제 메뉴) > table > global let query: string; let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 + // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) query = ` SELECT rule_id AS "ruleId", @@ -309,12 +299,20 @@ class NumberingRuleService { FROM numbering_rules WHERE scope_type = 'global' - OR (scope_type = 'menu' AND menu_objid = $1) - ORDER BY scope_type DESC, created_at DESC + OR scope_type = 'table' + OR (scope_type = 'menu' AND menu_objid = ANY($1)) + ORDER BY + CASE scope_type + WHEN 'menu' THEN 1 + WHEN 'table' THEN 2 + WHEN 'global' THEN 3 + END, + created_at DESC `; - params = [level2MenuObjid]; + params = [siblingObjids]; + logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회", { siblingObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 + // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함) query = ` SELECT rule_id AS "ruleId", @@ -335,58 +333,91 @@ class NumberingRuleService { WHERE company_code = $1 AND ( scope_type = 'global' - OR (scope_type = 'menu' AND menu_objid = $2) + OR scope_type = 'table' + OR (scope_type = 'menu' AND menu_objid = ANY($2)) ) - ORDER BY scope_type DESC, created_at DESC + ORDER BY + CASE scope_type + WHEN 'menu' THEN 1 + WHEN 'table' THEN 2 + WHEN 'global' THEN 3 + END, + created_at DESC `; - params = [companyCode, level2MenuObjid]; + params = [companyCode, siblingObjids]; + logger.info("회사별: 형제 메뉴 포함 채번 규칙 조회", { companyCode, siblingObjids }); } + logger.info("🔍 채번 규칙 쿼리 실행", { + queryPreview: query.substring(0, 200), + paramsTypes: params.map(p => typeof p), + paramsValues: params, + }); + const result = await pool.query(query, params); + logger.info("✅ 채번 규칙 쿼리 성공", { rowCount: result.rows.length }); + // 파트 정보 추가 for (const rule of result.rows) { - let partsQuery: string; - let partsParams: any[]; - - if (companyCode === "*") { - partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 - ORDER BY part_order - `; - partsParams = [rule.ruleId]; - } else { - partsQuery = ` - SELECT - id, - part_order AS "order", - part_type AS "partType", - generation_method AS "generationMethod", - auto_config AS "autoConfig", - manual_config AS "manualConfig" - FROM numbering_rule_parts - WHERE rule_id = $1 AND company_code = $2 - ORDER BY part_order - `; - partsParams = [rule.ruleId, companyCode]; - } + try { + let partsQuery: string; + let partsParams: any[]; + + if (companyCode === "*") { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + ORDER BY part_order + `; + partsParams = [rule.ruleId]; + } else { + partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 AND company_code = $2 + ORDER BY part_order + `; + partsParams = [rule.ruleId, companyCode]; + } - const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + const partsResult = await pool.query(partsQuery, partsParams); + rule.parts = partsResult.rows; + + logger.info("✅ 규칙 파트 조회 성공", { + ruleId: rule.ruleId, + ruleName: rule.ruleName, + partsCount: partsResult.rows.length, + }); + } catch (partError: any) { + logger.error("❌ 규칙 파트 조회 실패", { + ruleId: rule.ruleId, + ruleName: rule.ruleName, + error: partError.message, + errorCode: partError.code, + errorStack: partError.stack, + }); + throw partError; + } } logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - level2MenuObjid, + siblingCount: siblingObjids.length, count: result.rowCount, }); @@ -394,8 +425,11 @@ class NumberingRuleService { } catch (error: any) { logger.error("메뉴별 채번 규칙 조회 실패", { error: error.message, + errorCode: error.code, + errorStack: error.stack, companyCode, menuObjid, + siblingObjids: siblingObjids || [], }); throw error; } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 7646dead..29cad453 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1,5 +1,6 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { getSiblingMenuObjids } from "./menuService"; import { TableCategoryValue, CategoryColumn, @@ -79,84 +80,164 @@ class TableCategoryValueService { } /** - * 특정 컬럼의 카테고리 값 목록 조회 (테이블 스코프) + * 특정 컬럼의 카테고리 값 목록 조회 (메뉴 스코프) + * + * 메뉴 스코프 규칙: + * - menuObjid가 제공되면 해당 메뉴와 형제 메뉴의 카테고리 값을 조회 + * - menuObjid가 없으면 테이블 스코프로 동작 (하위 호환성) */ async getCategoryValues( tableName: string, columnName: string, companyCode: string, - includeInactive: boolean = false + includeInactive: boolean = false, + menuObjid?: number ): Promise { try { - logger.info("카테고리 값 목록 조회", { + logger.info("카테고리 값 목록 조회 (메뉴 스코프)", { tableName, columnName, companyCode, includeInactive, + menuObjid, }); const pool = getPool(); - // 멀티테넌시: 최고 관리자만 company_code="*" 데이터를 볼 수 있음 + // 1. 메뉴 스코프: 형제 메뉴 OBJID 조회 + let siblingObjids: number[] = []; + if (menuObjid) { + siblingObjids = await getSiblingMenuObjids(menuObjid); + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + } + + // 2. 카테고리 값 조회 (형제 메뉴 포함) let query: string; let params: any[]; if (companyCode === "*") { // 최고 관리자: 모든 카테고리 값 조회 - 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 - `; - params = [tableName, columnName]; + if (menuObjid && siblingObjids.length > 0) { + // 메뉴 스코프 적용 + 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", + menu_objid AS "menuObjid", + 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 menu_objid = ANY($3) + `; + params = [tableName, columnName, siblingObjids]; + } else { + // 테이블 스코프 (하위 호환성) + 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", + menu_objid AS "menuObjid", + 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 + `; + params = [tableName, columnName]; + } logger.info("최고 관리자 카테고리 값 조회"); } else { // 일반 회사: 자신의 카테고리 값만 조회 - 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 - `; - params = [tableName, columnName, companyCode]; + if (menuObjid && siblingObjids.length > 0) { + // 메뉴 스코프 적용 + 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", + menu_objid AS "menuObjid", + 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 menu_objid = ANY($3) + AND company_code = $4 + `; + params = [tableName, columnName, siblingObjids, companyCode]; + } else { + // 테이블 스코프 (하위 호환성) + 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", + menu_objid AS "menuObjid", + 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 + `; + params = [tableName, columnName, companyCode]; + } logger.info("회사별 카테고리 값 조회", { companyCode }); } @@ -175,6 +256,8 @@ class TableCategoryValueService { tableName, columnName, companyCode, + menuObjid, + scopeType: menuObjid ? "menu" : "table", }); return values; @@ -185,17 +268,31 @@ class TableCategoryValueService { } /** - * 카테고리 값 추가 + * 카테고리 값 추가 (메뉴 스코프) + * + * @param value 카테고리 값 정보 + * @param companyCode 회사 코드 + * @param userId 생성자 ID + * @param menuObjid 메뉴 OBJID (필수) */ async addCategoryValue( value: TableCategoryValue, companyCode: string, - userId: string + userId: string, + menuObjid: number ): Promise { const pool = getPool(); try { - // 중복 코드 체크 (멀티테넌시 적용) + logger.info("카테고리 값 추가 (메뉴 스코프)", { + tableName: value.tableName, + columnName: value.columnName, + valueCode: value.valueCode, + menuObjid, + companyCode, + }); + + // 중복 코드 체크 (멀티테넌시 + 메뉴 스코프) let duplicateQuery: string; let duplicateParams: any[]; @@ -207,8 +304,9 @@ class TableCategoryValueService { WHERE table_name = $1 AND column_name = $2 AND value_code = $3 + AND menu_objid = $4 `; - duplicateParams = [value.tableName, value.columnName, value.valueCode]; + duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid]; } else { // 일반 회사: 자신의 회사에서만 중복 체크 duplicateQuery = ` @@ -217,9 +315,10 @@ class TableCategoryValueService { WHERE table_name = $1 AND column_name = $2 AND value_code = $3 - AND company_code = $4 + AND menu_objid = $4 + AND company_code = $5 `; - duplicateParams = [value.tableName, value.columnName, value.valueCode, companyCode]; + duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid, companyCode]; } const duplicateResult = await pool.query(duplicateQuery, duplicateParams); @@ -232,8 +331,8 @@ class TableCategoryValueService { 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) + is_active, is_default, company_code, menu_objid, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING value_id AS "valueId", table_name AS "tableName", @@ -249,6 +348,7 @@ class TableCategoryValueService { is_active AS "isActive", is_default AS "isDefault", company_code AS "companyCode", + menu_objid AS "menuObjid", created_at AS "createdAt", created_by AS "createdBy" `; @@ -267,6 +367,7 @@ class TableCategoryValueService { value.isActive !== false, value.isDefault || false, companyCode, + menuObjid, // ← 메뉴 OBJID 저장 userId, ]); @@ -274,6 +375,7 @@ class TableCategoryValueService { valueId: result.rows[0].valueId, tableName: value.tableName, columnName: value.columnName, + menuObjid, }); return result.rows[0]; diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d7ea2039..5c2c587b 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useEffect, useState } from "react"; -import { useParams } from "next/navigation"; +import { useParams, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Loader2 } from "lucide-react"; import { screenApi } from "@/lib/api/screen"; @@ -21,8 +21,12 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감 export default function ScreenViewPage() { const params = useParams(); + const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); + + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) + const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; // 🆕 현재 로그인한 사용자 정보 const { user, userName, companyCode } = useAuth(); @@ -404,6 +408,7 @@ export default function ScreenViewPage() { userId={user?.userId} userName={userName} companyCode={companyCode} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 738aad79..252f5403 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -26,6 +26,7 @@ interface NumberingRuleDesignerProps { isPreview?: boolean; className?: string; currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) + menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) } export const NumberingRuleDesigner: React.FC = ({ @@ -36,6 +37,7 @@ export const NumberingRuleDesigner: React.FC = ({ isPreview = false, className = "", currentTableName, + menuObjid, }) => { const [savedRules, setSavedRules] = useState([]); const [selectedRuleId, setSelectedRuleId] = useState(null); @@ -53,7 +55,7 @@ export const NumberingRuleDesigner: React.FC = ({ const loadRules = useCallback(async () => { setLoading(true); try { - const response = await getNumberingRules(); + const response = await getNumberingRules(menuObjid); if (response.success && response.data) { setSavedRules(response.data); } else { @@ -64,7 +66,7 @@ export const NumberingRuleDesigner: React.FC = ({ } finally { setLoading(false); } - }, []); + }, [menuObjid]); useEffect(() => { if (currentRule) { @@ -145,7 +147,7 @@ export const NumberingRuleDesigner: React.FC = ({ "currentRule.tableName": currentRule.tableName, "ruleToSave.tableName": ruleToSave.tableName, "ruleToSave.scopeType": ruleToSave.scopeType, - ruleToSave + ruleToSave, }); let response; @@ -214,7 +216,7 @@ export const NumberingRuleDesigner: React.FC = ({ const handleNewRule = useCallback(() => { console.log("📋 새 규칙 생성 - currentTableName:", currentTableName); - + const newRule: NumberingRuleConfig = { ruleId: `rule-${Date.now()}`, ruleName: "새 채번 규칙", @@ -227,7 +229,7 @@ export const NumberingRuleDesigner: React.FC = ({ }; console.log("📋 생성된 규칙 정보:", newRule); - + setSelectedRuleId(newRule.ruleId); setCurrentRule(newRule); @@ -273,7 +275,7 @@ export const NumberingRuleDesigner: React.FC = ({ savedRules.map((rule) => ( handleSelectRule(rule)} @@ -356,7 +358,7 @@ export const NumberingRuleDesigner: React.FC = ({ {currentTableName && (
-
+
{currentTableName}

diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index fa5dc755..1f220586 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -41,6 +41,7 @@ interface RealtimePreviewProps { userId?: string; // 🆕 현재 사용자 ID userName?: string; // 🆕 현재 사용자 이름 companyCode?: string; // 🆕 현재 사용자의 회사 코드 + menuObjid?: number; // 🆕 현재 메뉴 OBJID (메뉴 스코프) selectedRowsData?: any[]; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; flowSelectedData?: any[]; @@ -107,6 +108,7 @@ export const RealtimePreviewDynamic: React.FC = ({ userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 + menuObjid, // 🆕 메뉴 OBJID selectedRowsData, onSelectedRowsChange, flowSelectedData, @@ -344,6 +346,7 @@ export const RealtimePreviewDynamic: React.FC = ({ userId={userId} userName={userName} companyCode={companyCode} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} onSelectedRowsChange={onSelectedRowsChange} flowSelectedData={flowSelectedData} diff --git a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx index 2e1f5087..cef462b9 100644 --- a/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx +++ b/frontend/components/screen/panels/webtype-configs/TextTypeConfigPanel.tsx @@ -14,7 +14,7 @@ interface TextTypeConfigPanelProps { config: TextTypeConfig; onConfigChange: (config: TextTypeConfig) => void; tableName?: string; // 화면의 테이블명 (선택) - menuObjid?: number; // 메뉴 objid (선택) + menuObjid?: number; // 메뉴 objid (선택) - 사용자가 선택한 부모 메뉴 } export const TextTypeConfigPanel: React.FC = ({ @@ -44,6 +44,10 @@ export const TextTypeConfigPanel: React.FC = ({ // 채번 규칙 목록 상태 const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); + + // 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택) + const [parentMenus, setParentMenus] = useState([]); + const [selectedMenuObjid, setSelectedMenuObjid] = useState(menuObjid); // 로컬 상태로 실시간 입력 관리 const [localValues, setLocalValues] = useState({ @@ -60,31 +64,61 @@ export const TextTypeConfigPanel: React.FC = ({ numberingRuleId: safeConfig.numberingRuleId, }); - // 채번 규칙 목록 로드 + // 부모 메뉴 목록 로드 (최상위 메뉴 또는 레벨 2 메뉴) + useEffect(() => { + const loadParentMenus = async () => { + try { + const { apiClient } = await import("@/lib/api/client"); + + // 관리자 메뉴와 사용자 메뉴 모두 가져오기 + const [adminResponse, userResponse] = await Promise.all([ + apiClient.get("/admin/menus", { params: { menuType: "0" } }), + apiClient.get("/admin/menus", { params: { menuType: "1" } }) + ]); + + const allMenus = [ + ...(adminResponse.data?.data || []), + ...(userResponse.data?.data || []) + ]; + + // 레벨 2 이하 메뉴만 선택 가능 (부모가 있는 메뉴) + const parentMenuList = allMenus.filter((menu: any) => { + const level = menu.lev || menu.LEV || 0; + return level >= 2; // 레벨 2 이상만 표시 (형제 메뉴가 있을 가능성) + }); + + setParentMenus(parentMenuList); + console.log("✅ 부모 메뉴 목록 로드:", parentMenuList.length); + } catch (error) { + console.error("❌ 부모 메뉴 목록 로드 실패:", error); + } + }; + + loadParentMenus(); + }, []); + + // 채번 규칙 목록 로드 (선택된 메뉴 기준) useEffect(() => { const loadRules = async () => { console.log("🔄 채번 규칙 로드 시작:", { autoValueType: localValues.autoValueType, + selectedMenuObjid, tableName, - hasTableName: !!tableName, }); + // 메뉴를 선택하지 않으면 로드하지 않음 + if (!selectedMenuObjid) { + console.warn("⚠️ 메뉴를 선택해야 채번 규칙을 조회할 수 있습니다"); + setNumberingRules([]); + return; + } + setLoadingRules(true); try { - let response; - - // 테이블명이 있으면 테이블 기반 필터링 사용 - if (tableName) { - console.log("📋 테이블 기반 채번 규칙 조회 API 호출:", { tableName }); - response = await getAvailableNumberingRulesForScreen(tableName); - console.log("📋 API 응답:", response); - } else { - // 테이블명이 없으면 빈 배열 (테이블 필수) - console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다"); - setNumberingRules([]); - setLoadingRules(false); - return; - } + // 선택된 메뉴의 채번 규칙 조회 (메뉴 스코프) + console.log("📋 메뉴 기반 채번 규칙 조회 API 호출:", { menuObjid: selectedMenuObjid }); + const response = await getAvailableNumberingRules(selectedMenuObjid); + console.log("📋 API 응답:", response); if (response.success && response.data) { setNumberingRules(response.data); @@ -93,7 +127,7 @@ export const TextTypeConfigPanel: React.FC = ({ rules: response.data.map((r: any) => ({ ruleId: r.ruleId, ruleName: r.ruleName, - tableName: r.tableName, + menuObjid: selectedMenuObjid, })), }); } else { @@ -115,7 +149,7 @@ export const TextTypeConfigPanel: React.FC = ({ } else { console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType); } - }, [localValues.autoValueType, tableName]); + }, [localValues.autoValueType, selectedMenuObjid]); // config가 변경될 때 로컬 상태 동기화 useEffect(() => { @@ -314,37 +348,95 @@ export const TextTypeConfigPanel: React.FC = ({

+ {(() => { + console.log("🔍 메뉴 선택 UI 렌더링 체크:", { + autoValueType: localValues.autoValueType, + isNumberingRule: localValues.autoValueType === "numbering_rule", + parentMenusCount: parentMenus.length, + selectedMenuObjid, + }); + return null; + })()} + {localValues.autoValueType === "numbering_rule" && ( -
- - setSelectedMenuObjid(parseInt(value))} + > + + + + + {parentMenus.length === 0 ? ( + + 사용 가능한 메뉴가 없습니다 - )) - )} - - -

- 현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다 -

-
+ ) : ( + parentMenus.map((menu) => { + const objid = menu.objid || menu.OBJID; + const menuName = menu.menu_name_kor || menu.MENU_NAME_KOR; + return ( + + {menuName} + + ); + }) + )} + + +

+ 이 필드가 어느 메뉴에서 사용될 것인지 선택하세요 +

+
+ + {/* 채번 규칙 선택 */} +
+ + +

+ 선택한 메뉴와 형제 메뉴에서 사용 가능한 채번 규칙만 표시됩니다 +

+
+ )} {localValues.autoValueType === "custom" && ( diff --git a/frontend/components/screen/widgets/CategoryWidget.tsx b/frontend/components/screen/widgets/CategoryWidget.tsx index 54c8f98b..3e1c7f7b 100644 --- a/frontend/components/screen/widgets/CategoryWidget.tsx +++ b/frontend/components/screen/widgets/CategoryWidget.tsx @@ -8,14 +8,15 @@ import { GripVertical } from "lucide-react"; interface CategoryWidgetProps { widgetId: string; tableName: string; // 현재 화면의 테이블 + menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) } /** * 카테고리 관리 위젯 (좌우 분할) * - 좌측: 현재 테이블의 카테고리 타입 컬럼 목록 - * - 우측: 선택된 컬럼의 카테고리 값 관리 (테이블 스코프) + * - 우측: 선택된 컬럼의 카테고리 값 관리 (메뉴 스코프) */ -export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) { +export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidgetProps) { const [selectedColumn, setSelectedColumn] = useState<{ columnName: string; columnLabel: string; @@ -69,6 +70,7 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) { onColumnSelect={(columnName, columnLabel) => setSelectedColumn({ columnName, columnLabel }) } + menuObjid={menuObjid} /> @@ -87,6 +89,7 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) { tableName={tableName} columnName={selectedColumn.columnName} columnLabel={selectedColumn.columnLabel} + menuObjid={menuObjid} /> ) : (
diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 7b7ebd32..db6af71c 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -16,13 +16,14 @@ interface CategoryColumnListProps { tableName: string; selectedColumn: string | null; onColumnSelect: (columnName: string, columnLabel: string) => void; + menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) } /** * 카테고리 컬럼 목록 (좌측 패널) - * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (테이블 스코프) + * - 현재 테이블에서 input_type='category'인 컬럼들을 표시 (메뉴 스코프) */ -export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }: CategoryColumnListProps) { +export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); @@ -89,7 +90,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect } // 각 컬럼의 값 개수 가져오기 let valueCount = 0; try { - const valuesResult = await getCategoryValues(tableName, colName, false); + const valuesResult = await getCategoryValues(tableName, colName, false, menuObjid); if (valuesResult.success && valuesResult.data) { valueCount = valuesResult.data.length; } diff --git a/frontend/components/table-category/CategoryValueManager.tsx b/frontend/components/table-category/CategoryValueManager.tsx index 0b7eecc3..dfcbd045 100644 --- a/frontend/components/table-category/CategoryValueManager.tsx +++ b/frontend/components/table-category/CategoryValueManager.tsx @@ -29,6 +29,7 @@ interface CategoryValueManagerProps { columnName: string; columnLabel: string; onValueCountChange?: (count: number) => void; + menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) } export const CategoryValueManager: React.FC = ({ @@ -36,6 +37,7 @@ export const CategoryValueManager: React.FC = ({ columnName, columnLabel, onValueCountChange, + menuObjid, }) => { const { toast } = useToast(); const [values, setValues] = useState([]); @@ -81,7 +83,7 @@ export const CategoryValueManager: React.FC = ({ setIsLoading(true); try { // includeInactive: true로 비활성 값도 포함 - const response = await getCategoryValues(tableName, columnName, true); + const response = await getCategoryValues(tableName, columnName, true, menuObjid); if (response.success && response.data) { setValues(response.data); setFilteredValues(response.data); @@ -101,11 +103,23 @@ export const CategoryValueManager: React.FC = ({ const handleAddValue = async (newValue: TableCategoryValue) => { try { - const response = await addCategoryValue({ - ...newValue, - tableName, - columnName, - }); + if (!menuObjid) { + toast({ + title: "오류", + description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.", + variant: "destructive", + }); + return; + } + + const response = await addCategoryValue( + { + ...newValue, + tableName, + columnName, + }, + menuObjid + ); if (response.success && response.data) { await loadCategoryValues(); @@ -128,7 +142,7 @@ export const CategoryValueManager: React.FC = ({ title: "오류", description: error.message || "카테고리 값 추가에 실패했습니다", variant: "destructive", - }); + }); } }; diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index ec927ac9..ee42c859 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -21,19 +21,30 @@ export async function getCategoryColumns(tableName: string) { } /** - * 카테고리 값 목록 조회 (테이블 스코프) + * 카테고리 값 목록 조회 (메뉴 스코프) + * + * @param tableName 테이블명 + * @param columnName 컬럼명 + * @param includeInactive 비활성 값 포함 여부 + * @param menuObjid 메뉴 OBJID (선택사항, 제공 시 형제 메뉴의 카테고리 값 포함) */ export async function getCategoryValues( tableName: string, columnName: string, - includeInactive: boolean = false + includeInactive: boolean = false, + menuObjid?: number ) { try { + const params: any = { includeInactive }; + if (menuObjid) { + params.menuObjid = menuObjid; + } + const response = await apiClient.get<{ success: boolean; data: TableCategoryValue[]; }>(`/table-categories/${tableName}/${columnName}/values`, { - params: { includeInactive }, + params, }); return response.data; } catch (error: any) { @@ -43,14 +54,23 @@ export async function getCategoryValues( } /** - * 카테고리 값 추가 + * 카테고리 값 추가 (메뉴 스코프) + * + * @param value 카테고리 값 정보 + * @param menuObjid 메뉴 OBJID (필수) */ -export async function addCategoryValue(value: TableCategoryValue) { +export async function addCategoryValue( + value: TableCategoryValue, + menuObjid: number +) { try { const response = await apiClient.post<{ success: boolean; data: TableCategoryValue; - }>("/table-categories/values", value); + }>("/table-categories/values", { + ...value, + menuObjid, // ← menuObjid 포함 + }); return response.data; } catch (error: any) { console.error("카테고리 값 추가 실패:", error); diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 19d61cb0..1791e9b0 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -98,6 +98,7 @@ export interface DynamicComponentRendererProps { screenId?: number; tableName?: string; menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요) + menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번) selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용) userId?: string; // 🆕 현재 사용자 ID userName?: string; // 🆕 현재 사용자 이름 @@ -224,6 +225,7 @@ export const DynamicComponentRenderer: React.FC = onFormDataChange, tableName, menuId, // 🆕 메뉴 ID + menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 onRefresh, onClose, @@ -319,6 +321,7 @@ export const DynamicComponentRenderer: React.FC = onChange: handleChange, // 개선된 onChange 핸들러 전달 tableName, menuId, // 🆕 메뉴 ID + menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프) selectedScreen, // 🆕 화면 정보 onRefresh, onClose, diff --git a/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx b/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx index f487b320..817baf57 100644 --- a/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx +++ b/frontend/lib/registry/components/text-input/TextInputConfigPanel.tsx @@ -15,32 +15,65 @@ export interface TextInputConfigPanelProps { config: TextInputConfig; onChange: (config: Partial) => void; screenTableName?: string; // 🆕 현재 화면의 테이블명 + menuObjid?: number; // 🆕 메뉴 OBJID (사용자 선택) } /** * TextInput 설정 패널 * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ -export const TextInputConfigPanel: React.FC = ({ config, onChange, screenTableName }) => { +export const TextInputConfigPanel: React.FC = ({ config, onChange, screenTableName, menuObjid }) => { // 채번 규칙 목록 상태 const [numberingRules, setNumberingRules] = useState([]); const [loadingRules, setLoadingRules] = useState(false); + + // 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택) + const [parentMenus, setParentMenus] = useState([]); + const [selectedMenuObjid, setSelectedMenuObjid] = useState(menuObjid); + const [loadingMenus, setLoadingMenus] = useState(false); - // 채번 규칙 목록 로드 + // 부모 메뉴 목록 로드 (사용자 메뉴의 레벨 2만) + useEffect(() => { + const loadMenus = async () => { + setLoadingMenus(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get("/admin/menus"); + + if (response.data.success && response.data.data) { + const allMenus = response.data.data; + + // 사용자 메뉴(menu_type='1')의 레벨 2만 필터링 + const level2UserMenus = allMenus.filter((menu: any) => + menu.menu_type === '1' && menu.lev === 2 + ); + + setParentMenus(level2UserMenus); + console.log("✅ 부모 메뉴 로드 완료:", level2UserMenus.length, "개", level2UserMenus); + } + } catch (error) { + console.error("부모 메뉴 로드 실패:", error); + } finally { + setLoadingMenus(false); + } + }; + loadMenus(); + }, []); + + // 채번 규칙 목록 로드 (선택된 메뉴 기준) useEffect(() => { const loadRules = async () => { + // 메뉴가 선택되지 않았으면 로드하지 않음 + if (!selectedMenuObjid) { + console.log("⚠️ 메뉴가 선택되지 않아 채번 규칙을 로드하지 않습니다"); + setNumberingRules([]); + return; + } + setLoadingRules(true); try { - let response; - - // 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회 - if (screenTableName) { - console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName }); - response = await getAvailableNumberingRulesForScreen(screenTableName); - } else { - console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)"); - response = await getAvailableNumberingRules(); - } + console.log("🔍 선택된 메뉴 기반 채번 규칙 로드", { selectedMenuObjid }); + const response = await getAvailableNumberingRules(selectedMenuObjid); if (response.success && response.data) { setNumberingRules(response.data); @@ -48,6 +81,7 @@ export const TextInputConfigPanel: React.FC = ({ conf } } catch (error) { console.error("채번 규칙 목록 로드 실패:", error); + setNumberingRules([]); } finally { setLoadingRules(false); } @@ -57,7 +91,7 @@ export const TextInputConfigPanel: React.FC = ({ conf if (config.autoGeneration?.type === "numbering_rule") { loadRules(); } - }, [config.autoGeneration?.type, screenTableName]); + }, [config.autoGeneration?.type, selectedMenuObjid]); const handleChange = (key: keyof TextInputConfig, value: any) => { onChange({ [key]: value }); @@ -157,50 +191,100 @@ export const TextInputConfigPanel: React.FC = ({ conf {/* 채번 규칙 선택 */} {config.autoGeneration?.type === "numbering_rule" && ( -
- - { + const menuObjid = parseInt(value); + setSelectedMenuObjid(menuObjid); + console.log("✅ 메뉴 선택됨:", menuObjid); + }} + disabled={loadingMenus} + > + + + + + {parentMenus.length === 0 ? ( + + 사용 가능한 메뉴가 없습니다 - )) - )} - - -

- 현재 메뉴에서 사용 가능한 채번 규칙만 표시됩니다 -

-
+ ) : ( + parentMenus.map((menu) => ( + + {menu.menu_name_kor} + {menu.menu_name_eng && ( + + ({menu.menu_name_eng}) + + )} + + )) + )} + + +

+ 이 입력 필드가 어느 메뉴에 속할지 선택하세요 (해당 메뉴의 채번규칙이 적용됩니다) +

+
+ + {/* 채번 규칙 선택 (메뉴 선택 후) */} + {selectedMenuObjid ? ( +
+ + +

+ 선택된 메뉴 및 형제 메뉴에서 사용 가능한 채번 규칙만 표시됩니다 +

+
+ ) : ( +
+ 먼저 대상 메뉴를 선택하세요 +
+ )} + )} )} diff --git a/카테고리_채번_메뉴스코프_전환_통합_계획서.md b/카테고리_채번_메뉴스코프_전환_통합_계획서.md new file mode 100644 index 00000000..ac8b0d79 --- /dev/null +++ b/카테고리_채번_메뉴스코프_전환_통합_계획서.md @@ -0,0 +1,1004 @@ +# 카테고리 및 채번규칙 메뉴 스코프 전환 통합 계획서 + +## 📋 현재 문제점 분석 + +### 테이블 기반 스코프의 근본적 한계 + +**현재 상황**: +- 카테고리 시스템: `table_column_category_values` 테이블에서 `table_name + column_name`으로 데이터 조회 +- 채번규칙 시스템: `numbering_rules` 테이블에서 `table_name`으로 데이터 조회 + +**발생하는 문제**: + +``` +영업관리 (menu_objid: 200) +├── 고객관리 (menu_objid: 201) - 테이블: customer_info +├── 계약관리 (menu_objid: 202) - 테이블: contract_info +├── 주문관리 (menu_objid: 203) - 테이블: order_info +└── 공통코드 관리 (menu_objid: 204) - 어떤 테이블 선택? +``` + +**문제 1**: 형제 메뉴 간 코드 공유 불가 +- 고객관리, 계약관리, 주문관리가 모두 다른 테이블 사용 +- 각 화면마다 **동일한 카테고리/채번규칙을 중복 생성**해야 함 +- "고객 유형" 같은 공통 카테고리를 3번 만들어야 함 + +**문제 2**: 공통코드 관리 화면 불가능 +- 영업관리 전체에서 사용할 공통코드를 관리하려면 +- 특정 테이블 하나를 선택해야 하는데 +- 그러면 다른 테이블을 사용하는 형제 메뉴에서 접근 불가 + +**문제 3**: 비효율적인 유지보수 +- 같은 코드를 여러 테이블에 중복 관리 +- 하나의 값을 수정하려면 모든 테이블에서 수정 필요 +- 데이터 불일치 발생 가능 + +--- + +## ✅ 해결 방안: 메뉴 기반 스코프 + +### 핵심 개념 + +**메뉴 계층 구조를 데이터 스코프로 사용**: +- 카테고리/채번규칙 생성 시 `menu_objid`를 기록 +- 같은 부모 메뉴를 가진 **형제 메뉴들**이 데이터를 공유 +- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정 + +### 메뉴 스코프 규칙 + +``` +영업관리 (parent_id: 0, menu_objid: 200) +├── 고객관리 (parent_id: 200, menu_objid: 201) +├── 계약관리 (parent_id: 200, menu_objid: 202) +├── 주문관리 (parent_id: 200, menu_objid: 203) +└── 공통코드 관리 (parent_id: 200, menu_objid: 204) ← 여기서 생성 +``` + +**스코프 규칙**: +1. 204번 메뉴에서 카테고리 생성 → `menu_objid = 204`로 저장 +2. 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능** +3. 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가 + +### 이점 + +✅ **형제 메뉴 간 코드 공유**: 한 번 생성하면 모든 형제 메뉴에서 사용 +✅ **공통코드 관리 화면 가능**: 전용 메뉴에서 일괄 관리 +✅ **테이블 독립성**: 테이블이 달라도 같은 카테고리 사용 가능 +✅ **직관적인 관리**: 메뉴 구조가 곧 데이터 스코프 +✅ **유지보수 용이**: 한 곳에서 수정하면 모든 형제 메뉴에 반영 + +--- + +## 📐 데이터베이스 설계 + +### 1. 카테고리 시스템 마이그레이션 + +#### 기존 상태 +```sql +-- table_column_category_values 테이블 +table_name | column_name | value_code | company_code +customer_info | customer_type | REGULAR | COMPANY_A +customer_info | customer_type | VIP | COMPANY_A +``` + +**문제**: `contract_info` 테이블에서는 이 카테고리를 사용할 수 없음 + +#### 변경 후 +```sql +-- table_column_category_values 테이블에 menu_objid 추가 +table_name | column_name | value_code | menu_objid | company_code +customer_info | customer_type | REGULAR | 204 | COMPANY_A +customer_info | customer_type | VIP | 204 | COMPANY_A +``` + +**해결**: menu_objid=204의 형제 메뉴(201,202,203,204)에서 모두 사용 가능 + +#### 마이그레이션 SQL + +```sql +-- db/migrations/048_convert_category_to_menu_scope.sql + +-- 1. menu_objid 컬럼 추가 (NULL 허용) +ALTER TABLE table_column_category_values +ADD COLUMN IF NOT EXISTS menu_objid NUMERIC; + +COMMENT ON COLUMN table_column_category_values.menu_objid +IS '카테고리를 생성한 메뉴 OBJID (형제 메뉴에서 공유)'; + +-- 2. 기존 데이터에 임시 menu_objid 설정 +-- 첫 번째 메뉴의 objid를 가져와서 설정 +DO $$ +DECLARE + first_menu_objid NUMERIC; +BEGIN + SELECT objid INTO first_menu_objid FROM menu_info LIMIT 1; + + IF first_menu_objid IS NOT NULL THEN + UPDATE table_column_category_values + SET menu_objid = first_menu_objid + WHERE menu_objid IS NULL; + + RAISE NOTICE '기존 카테고리 데이터의 menu_objid를 %로 설정했습니다', first_menu_objid; + RAISE NOTICE '관리자가 수동으로 올바른 menu_objid로 변경해야 합니다'; + END IF; +END $$; + +-- 3. menu_objid를 NOT NULL로 변경 +ALTER TABLE table_column_category_values +ALTER COLUMN menu_objid SET NOT NULL; + +-- 4. 외래키 추가 +ALTER TABLE table_column_category_values +ADD CONSTRAINT fk_category_value_menu +FOREIGN KEY (menu_objid) REFERENCES menu_info(objid) +ON DELETE CASCADE; + +-- 5. 기존 UNIQUE 제약조건 삭제 +ALTER TABLE table_column_category_values +DROP CONSTRAINT IF EXISTS unique_category_value; + +ALTER TABLE table_column_category_values +DROP CONSTRAINT IF EXISTS table_column_category_values_table_name_column_name_value_key; + +-- 6. 새로운 UNIQUE 제약조건 추가 (menu_objid 포함) +ALTER TABLE table_column_category_values +ADD CONSTRAINT unique_category_value +UNIQUE (table_name, column_name, value_code, menu_objid, company_code); + +-- 7. 인덱스 추가 (성능 최적화) +CREATE INDEX IF NOT EXISTS idx_category_value_menu +ON table_column_category_values(menu_objid, table_name, column_name, company_code); + +CREATE INDEX IF NOT EXISTS idx_category_value_company +ON table_column_category_values(company_code, table_name, column_name); +``` + +### 2. 채번규칙 시스템 마이그레이션 + +#### 기존 상태 +```sql +-- numbering_rules 테이블 +rule_id | table_name | scope_type | company_code +ITEM_CODE | item_info | table | COMPANY_A +``` + +**문제**: `item_info` 테이블을 사용하는 화면에서만 이 규칙 사용 가능 + +#### 변경 후 +```sql +-- numbering_rules 테이블 (menu_objid 추가) +rule_id | table_name | scope_type | menu_objid | company_code +ITEM_CODE | item_info | menu | 204 | COMPANY_A +``` + +**해결**: menu_objid=204의 형제 메뉴에서 모두 사용 가능 + +#### 마이그레이션 SQL + +```sql +-- db/migrations/049_convert_numbering_to_menu_scope.sql + +-- 1. menu_objid 컬럼 추가 (이미 존재하면 스킵) +ALTER TABLE numbering_rules +ADD COLUMN IF NOT EXISTS menu_objid NUMERIC; + +COMMENT ON COLUMN numbering_rules.menu_objid +IS '채번규칙을 생성한 메뉴 OBJID (형제 메뉴에서 공유)'; + +-- 2. 기존 데이터 마이그레이션 +DO $$ +DECLARE + first_menu_objid NUMERIC; +BEGIN + SELECT objid INTO first_menu_objid FROM menu_info LIMIT 1; + + IF first_menu_objid IS NOT NULL THEN + -- scope_type='table'이고 menu_objid가 NULL인 규칙들을 + -- scope_type='menu'로 변경하고 임시 menu_objid 설정 + UPDATE numbering_rules + SET scope_type = 'menu', + menu_objid = first_menu_objid + WHERE scope_type = 'table' + AND menu_objid IS NULL; + + RAISE NOTICE '기존 채번규칙의 scope_type을 menu로 변경하고 menu_objid를 %로 설정했습니다', first_menu_objid; + RAISE NOTICE '관리자가 수동으로 올바른 menu_objid로 변경해야 합니다'; + END IF; +END $$; + +-- 3. 제약조건 수정 +-- menu 타입은 menu_objid 필수 +ALTER TABLE numbering_rules +DROP CONSTRAINT IF EXISTS check_menu_scope_requires_menu_objid; + +ALTER TABLE numbering_rules +ADD CONSTRAINT check_menu_scope_requires_menu_objid +CHECK ( + (scope_type != 'menu') OR + (scope_type = 'menu' AND menu_objid IS NOT NULL) +); + +-- 4. 외래키 추가 (menu_objid → menu_info.objid) +ALTER TABLE numbering_rules +DROP CONSTRAINT IF EXISTS fk_numbering_rule_menu; + +ALTER TABLE numbering_rules +ADD CONSTRAINT fk_numbering_rule_menu +FOREIGN KEY (menu_objid) REFERENCES menu_info(objid) +ON DELETE CASCADE; + +-- 5. 인덱스 추가 (성능 최적화) +CREATE INDEX IF NOT EXISTS idx_numbering_rules_menu +ON numbering_rules(menu_objid, company_code); +``` + +--- + +## 🔧 백엔드 구현 + +### 1. 공통 유틸리티: 형제 메뉴 조회 + +```typescript +// backend-node/src/services/menuService.ts (신규 파일) + +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +/** + * 메뉴의 형제 메뉴 OBJID 목록 조회 + * (같은 부모를 가진 메뉴들) + * + * @param menuObjid 현재 메뉴의 OBJID + * @returns 형제 메뉴 OBJID 배열 (자기 자신 포함) + */ +export async function getSiblingMenuObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.info("형제 메뉴 조회 시작", { menuObjid }); + + // 1. 현재 메뉴의 부모 찾기 + const parentQuery = ` + SELECT parent_id FROM menu_info WHERE objid = $1 + `; + const parentResult = await pool.query(parentQuery, [menuObjid]); + + if (parentResult.rows.length === 0) { + logger.warn("메뉴를 찾을 수 없음", { menuObjid }); + return [menuObjid]; // 메뉴가 없으면 자기 자신만 + } + + const parentId = parentResult.rows[0].parent_id; + + if (!parentId || parentId === 0) { + // 최상위 메뉴인 경우 자기 자신만 + logger.info("최상위 메뉴 (형제 없음)", { menuObjid }); + return [menuObjid]; + } + + // 2. 같은 부모를 가진 형제 메뉴들 조회 + const siblingsQuery = ` + SELECT objid FROM menu_info WHERE parent_id = $1 ORDER BY objid + `; + const siblingsResult = await pool.query(siblingsQuery, [parentId]); + + const siblingObjids = siblingsResult.rows.map((row) => row.objid); + + logger.info("형제 메뉴 조회 완료", { + menuObjid, + parentId, + siblingCount: siblingObjids.length, + siblings: siblingObjids, + }); + + return siblingObjids; + } catch (error: any) { + logger.error("형제 메뉴 조회 실패", { menuObjid, error: error.message }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + +/** + * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 + * + * @param menuObjids 메뉴 OBJID 배열 + * @returns 모든 형제 메뉴 OBJID 배열 (중복 제거) + */ +export async function getAllSiblingMenuObjids( + menuObjids: number[] +): Promise { + if (!menuObjids || menuObjids.length === 0) { + return []; + } + + const allSiblings = new Set(); + + for (const objid of menuObjids) { + const siblings = await getSiblingMenuObjids(objid); + siblings.forEach((s) => allSiblings.add(s)); + } + + return Array.from(allSiblings).sort((a, b) => a - b); +} +``` + +### 2. 카테고리 서비스 수정 + +```typescript +// backend-node/src/services/tableCategoryValueService.ts + +import { getSiblingMenuObjids } from "./menuService"; + +class TableCategoryValueService { + /** + * 카테고리 값 목록 조회 (메뉴 스코프 적용) + */ + async getCategoryValues( + tableName: string, + columnName: string, + menuObjid: number, // ← 추가 + companyCode: string, + includeInactive: boolean = false + ): Promise { + logger.info("카테고리 값 조회 (메뉴 스코프)", { + tableName, + columnName, + menuObjid, + companyCode, + }); + + const pool = getPool(); + + // 1. 형제 메뉴 OBJID 조회 + const siblingObjids = await getSiblingMenuObjids(menuObjid); + + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + + // 2. 카테고리 값 조회 (형제 메뉴 포함) + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 + 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", + menu_objid AS "menuObjid", + created_at AS "createdAt", + created_by AS "createdBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_objid = ANY($3) -- ← 형제 메뉴 포함 + ${!includeInactive ? 'AND is_active = true' : ''} + ORDER BY value_order, value_label + `; + params = [tableName, columnName, siblingObjids]; + } else { + // 일반 회사: 자신의 데이터만 조회 + 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", + menu_objid AS "menuObjid", + created_at AS "createdAt", + created_by AS "createdBy" + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND menu_objid = ANY($3) -- ← 형제 메뉴 포함 + AND company_code = $4 -- ← 회사별 필터링 + ${!includeInactive ? 'AND is_active = true' : ''} + ORDER BY value_order, value_label + `; + params = [tableName, columnName, siblingObjids, companyCode]; + } + + const result = await pool.query(query, params); + + logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`); + + return result.rows; + } + + /** + * 카테고리 값 추가 (menu_objid 저장) + */ + async addCategoryValue( + value: TableCategoryValue, + menuObjid: number, // ← 추가 + companyCode: string, + userId: string + ): Promise { + logger.info("카테고리 값 추가 (메뉴 스코프)", { + tableName: value.tableName, + columnName: value.columnName, + valueCode: value.valueCode, + menuObjid, + companyCode, + }); + + const pool = getPool(); + + const query = ` + 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, menu_objid, -- ← menu_objid 추가 + created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + 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", + menu_objid AS "menuObjid", + created_at AS "createdAt", + created_by AS "createdBy" + `; + + const result = await pool.query(query, [ + 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, + menuObjid, // ← 카테고리 관리 화면의 menu_objid + userId, + ]); + + logger.info("카테고리 값 추가 성공", { + valueId: result.rows[0].valueId, + menuObjid, + }); + + return result.rows[0]; + } + + // 수정, 삭제 메서드도 동일하게 menuObjid 파라미터 추가 +} + +export default TableCategoryValueService; +``` + +### 3. 채번규칙 서비스 수정 + +```typescript +// backend-node/src/services/numberingRuleService.ts + +import { getSiblingMenuObjids } from "./menuService"; + +class NumberingRuleService { + /** + * 화면용 채번 규칙 조회 (메뉴 스코프 적용) + */ + async getAvailableRulesForScreen( + companyCode: string, + tableName: string, + menuObjid?: number + ): Promise { + logger.info("화면용 채번 규칙 조회 (메뉴 스코프)", { + companyCode, + tableName, + menuObjid, + }); + + const pool = getPool(); + + // 1. 형제 메뉴 OBJID 조회 + let siblingObjids: number[] = []; + if (menuObjid) { + siblingObjids = await getSiblingMenuObjids(menuObjid); + logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + } + + // 2. 채번 규칙 조회 (우선순위: menu > table > global) + let query: string; + let params: any[]; + + if (companyCode === "*") { + // 최고 관리자: 모든 회사 데이터 조회 (company_code="*" 제외) + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code != '*' + AND ( + ${ + siblingObjids.length > 0 + ? `(scope_type = 'menu' AND menu_objid = ANY($1)) OR` + : "" + } + (scope_type = 'table' AND table_name = $${siblingObjids.length > 0 ? 2 : 1}) + OR (scope_type = 'global' AND table_name IS NULL) + ) + ORDER BY + CASE scope_type + WHEN 'menu' THEN 1 + WHEN 'table' THEN 2 + WHEN 'global' THEN 3 + END, + created_at DESC + `; + params = siblingObjids.length > 0 ? [siblingObjids, tableName] : [tableName]; + } else { + // 일반 회사: 자신의 규칙만 조회 + query = ` + SELECT + rule_id AS "ruleId", + rule_name AS "ruleName", + description, + separator, + reset_period AS "resetPeriod", + current_sequence AS "currentSequence", + table_name AS "tableName", + column_name AS "columnName", + company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", + created_at AS "createdAt", + updated_at AS "updatedAt", + created_by AS "createdBy" + FROM numbering_rules + WHERE company_code = $1 + AND ( + ${ + siblingObjids.length > 0 + ? `(scope_type = 'menu' AND menu_objid = ANY($2)) OR` + : "" + } + (scope_type = 'table' AND table_name = $${siblingObjids.length > 0 ? 3 : 2}) + OR (scope_type = 'global' AND table_name IS NULL) + ) + ORDER BY + CASE scope_type + WHEN 'menu' THEN 1 + WHEN 'table' THEN 2 + WHEN 'global' THEN 3 + END, + created_at DESC + `; + params = siblingObjids.length > 0 + ? [companyCode, siblingObjids, tableName] + : [companyCode, tableName]; + } + + const result = await pool.query(query, params); + + // 각 규칙의 파트 정보 로드 + for (const rule of result.rows) { + const partsQuery = ` + SELECT + id, + part_order AS "order", + part_type AS "partType", + generation_method AS "generationMethod", + auto_config AS "autoConfig", + manual_config AS "manualConfig" + FROM numbering_rule_parts + WHERE rule_id = $1 + AND company_code = $2 + ORDER BY part_order + `; + + const partsResult = await pool.query(partsQuery, [ + rule.ruleId, + companyCode === "*" ? rule.companyCode : companyCode, + ]); + + rule.parts = partsResult.rows; + } + + logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`); + + return result.rows; + } +} + +export default NumberingRuleService; +``` + +### 4. 컨트롤러 수정 + +```typescript +// backend-node/src/controllers/tableCategoryValueController.ts + +/** + * 카테고리 값 목록 조회 + */ +export async function getCategoryValues( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { menuObjid, includeInactive } = req.query; // ← menuObjid 추가 + const companyCode = req.user!.companyCode; + + if (!menuObjid) { + res.status(400).json({ + success: false, + message: "menuObjid는 필수입니다", + }); + return; + } + + const service = new TableCategoryValueService(); + const values = await service.getCategoryValues( + tableName, + columnName, + Number(menuObjid), // ← menuObjid 전달 + companyCode, + includeInactive === "true" + ); + + res.json({ + success: true, + data: values, + }); + } catch (error: any) { + logger.error("카테고리 값 조회 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 값 조회 중 오류 발생", + error: error.message, + }); + } +} + +/** + * 카테고리 값 추가 + */ +export async function addCategoryValue( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { menuObjid, ...value } = req.body; // ← menuObjid 추가 + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + if (!menuObjid) { + res.status(400).json({ + success: false, + message: "menuObjid는 필수입니다", + }); + return; + } + + const service = new TableCategoryValueService(); + const newValue = await service.addCategoryValue( + value, + menuObjid, // ← menuObjid 전달 + companyCode, + userId + ); + + res.json({ + success: true, + data: newValue, + }); + } catch (error: any) { + logger.error("카테고리 값 추가 실패:", error); + res.status(500).json({ + success: false, + message: "카테고리 값 추가 중 오류 발생", + error: error.message, + }); + } +} +``` + +--- + +## 🎨 프론트엔드 구현 + +### 1. API 클라이언트 수정 + +```typescript +// frontend/lib/api/tableCategoryValue.ts + +/** + * 카테고리 값 목록 조회 (메뉴 스코프) + */ +export async function getCategoryValues( + tableName: string, + columnName: string, + menuObjid: number, // ← 추가 + includeInactive: boolean = false +) { + try { + const response = await apiClient.get<{ + success: boolean; + data: TableCategoryValue[]; + }>(`/table-categories/${tableName}/${columnName}/values`, { + params: { + menuObjid, // ← menuObjid 쿼리 파라미터 추가 + includeInactive, + }, + }); + return response.data; + } catch (error: any) { + console.error("카테고리 값 조회 실패:", error); + return { success: false, error: error.message }; + } +} + +/** + * 카테고리 값 추가 + */ +export async function addCategoryValue( + value: TableCategoryValue, + menuObjid: number // ← 추가 +) { + try { + const response = await apiClient.post<{ + success: boolean; + data: TableCategoryValue; + }>("/table-categories/values", { + ...value, + menuObjid, // ← menuObjid 포함 + }); + return response.data; + } catch (error: any) { + console.error("카테고리 값 추가 실패:", error); + return { success: false, error: error.message }; + } +} +``` + +### 2. 화면관리 시스템에서 menuObjid 전달 + +```typescript +// frontend/components/screen/ScreenDesigner.tsx + +export function ScreenDesigner() { + const [selectedScreen, setSelectedScreen] = useState(null); + + // 선택된 화면의 menuObjid 추출 + const currentMenuObjid = selectedScreen?.menuObjid; + + return ( +
+ {/* 모든 카테고리/채번 관련 컴포넌트에 menuObjid 전달 */} + +
+ ); +} +``` + +### 3. 컴포넌트 props 수정 + +모든 카테고리/채번 관련 컴포넌트에 `menuObjid: number` prop 추가: + +- `CategoryColumnList` +- `CategoryValueManager` +- `NumberingRuleSelector` +- `TextTypeConfigPanel` + +--- + +## 📊 사용 시나리오 + +### 시나리오: 영업관리 공통코드 관리 + +#### 1단계: 메뉴 구조 + +``` +영업관리 (parent_id: 0, menu_objid: 200) +├── 고객관리 (parent_id: 200, menu_objid: 201) - customer_info 테이블 +├── 계약관리 (parent_id: 200, menu_objid: 202) - contract_info 테이블 +├── 주문관리 (parent_id: 200, menu_objid: 203) - order_info 테이블 +└── 공통코드 관리 (parent_id: 200, menu_objid: 204) - 카테고리 관리 전용 +``` + +#### 2단계: 카테고리 생성 + +1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_objid: 204) +2. **화면 생성**: 화면관리 시스템에서 화면 생성 +3. **테이블 선택**: `customer_info` (어떤 테이블이든 상관없음) +4. **카테고리 값 추가**: + - 컬럼: `customer_type` + - 값: `REGULAR (일반 고객)`, `VIP (VIP 고객)` + - **저장 시 `menu_objid = 204`로 자동 저장** + +#### 3단계: 형제 메뉴에서 사용 + +**고객관리 화면** (menu_objid: 201): +- ✅ `customer_type` 드롭다운에 `일반 고객`, `VIP 고객` 표시 +- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴 + +**계약관리 화면** (menu_objid: 202): +- ✅ `customer_type` 컬럼에 동일한 카테고리 사용 가능 +- **이유**: 202와 204도 형제 메뉴 + +**구매관리 > 발주관리** (parent_id: 300): +- ❌ 영업관리의 카테고리는 표시되지 않음 +- **이유**: 다른 부모 메뉴이므로 스코프가 다름 + +--- + +## 📝 구현 순서 + +### Phase 1: 데이터베이스 마이그레이션 (1시간) + +- [ ] `048_convert_category_to_menu_scope.sql` 작성 및 실행 +- [ ] `049_convert_numbering_to_menu_scope.sql` 작성 및 실행 +- [ ] 기존 데이터 확인 및 임시 menu_objid 정리 계획 수립 + +### Phase 2: 백엔드 구현 (3-4시간) + +- [ ] `menuService.ts` 신규 파일 생성 (`getSiblingMenuObjids()` 함수) +- [ ] `tableCategoryValueService.ts` 수정 (menuObjid 파라미터 추가) +- [ ] `numberingRuleService.ts` 수정 (menuObjid 파라미터 추가) +- [ ] 컨트롤러 수정 (쿼리 파라미터에서 menuObjid 추출) +- [ ] 백엔드 테스트 + +### Phase 3: 프론트엔드 API 클라이언트 (1시간) + +- [ ] `tableCategoryValue.ts` API 클라이언트 수정 +- [ ] `numberingRule.ts` API 클라이언트 수정 + +### Phase 4: 프론트엔드 컴포넌트 (3-4시간) + +- [ ] `CategoryColumnList.tsx` 수정 (menuObjid prop 추가) +- [ ] `CategoryValueManager.tsx` 수정 (menuObjid prop 추가) +- [ ] `NumberingRuleSelector.tsx` 수정 (menuObjid prop 추가) +- [ ] `TextTypeConfigPanel.tsx` 수정 (menuObjid prop 추가) +- [ ] 모든 컴포넌트에서 API 호출 시 menuObjid 전달 + +### Phase 5: 화면관리 시스템 통합 (2시간) + +- [ ] `ScreenDesigner.tsx`에서 menuObjid 추출 및 전달 +- [ ] 카테고리 관리 화면 테스트 +- [ ] 채번규칙 설정 화면 테스트 + +### Phase 6: 테스트 및 문서화 (2시간) + +- [ ] 전체 플로우 테스트 +- [ ] 메뉴 스코프 동작 검증 +- [ ] 사용 가이드 작성 + +**총 예상 시간**: 12-15시간 + +--- + +## 🧪 테스트 체크리스트 + +### 데이터베이스 테스트 + +- [ ] 마이그레이션 정상 실행 확인 +- [ ] menu_objid 외래키 제약조건 확인 +- [ ] UNIQUE 제약조건 확인 (menu_objid 포함) +- [ ] 인덱스 생성 확인 + +### 백엔드 테스트 + +- [ ] `getSiblingMenuObjids()` 함수가 올바른 형제 메뉴 반환 +- [ ] 최상위 메뉴의 경우 자기 자신만 반환 +- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함 +- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음 +- [ ] 멀티테넌시 필터링 정상 작동 + +### 프론트엔드 테스트 + +- [ ] 카테고리 컬럼 목록 정상 표시 +- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함) +- [ ] 카테고리 값 추가 시 menuObjid 포함 +- [ ] 채번규칙 목록 정상 표시 (형제 메뉴 포함) +- [ ] 모든 CRUD 작업 정상 작동 + +### 통합 테스트 + +- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성 +- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능 +- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능 +- [ ] 구매관리에서는 영업관리 카테고리 사용 불가 +- [ ] 채번규칙도 동일하게 동작하는지 확인 + +--- + +## 💡 이점 요약 + +### 1. 형제 메뉴 간 데이터 공유 +- 같은 부서의 화면들이 카테고리/채번규칙 공유 +- 중복 생성 불필요 + +### 2. 공통코드 관리 화면 가능 +- 전용 메뉴에서 일괄 관리 +- 한 곳에서 수정하면 모든 형제 메뉴에 반영 + +### 3. 테이블 독립성 +- 테이블이 달라도 같은 카테고리 사용 가능 +- 테이블 구조 변경에 영향 없음 + +### 4. 직관적인 관리 +- 메뉴 구조가 곧 데이터 스코프 +- 이해하기 쉬운 권한 체계 + +### 5. 유지보수 용이 +- 한 곳에서 수정하면 자동 반영 +- 데이터 불일치 방지 + +--- + +## 🚀 다음 단계 + +### 1. 계획 승인 +이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다. + +### 2. 단계별 구현 +Phase 1부터 순차적으로 구현하여 안정성 확보 + +### 3. 점진적 마이그레이션 +기존 데이터를 점진적으로 올바른 menu_objid로 정리 + +--- + +**이 계획서대로 구현하면 테이블 기반 스코프의 한계를 완전히 극복하고, 메뉴 구조 기반의 직관적인 데이터 관리 시스템을 구축할 수 있습니다.** + +구현을 시작할까요? +