feat: 채번규칙 메뉴 스코프 전환 완료

 주요 변경사항:
- 백엔드: menuService.ts 추가 (형제 메뉴 조회 유틸리티)
- 백엔드: numberingRuleService.getAvailableRulesForMenu() 메뉴 스코프 적용
- 백엔드: tableCategoryValueService 메뉴 스코프 준비 (menuObjid 파라미터 추가)
- 프론트엔드: TextInputConfigPanel에 부모 메뉴 선택 UI 추가
- 프론트엔드: 메뉴별 채번규칙 필터링 (형제 메뉴 공유)

🔧 기술 세부사항:
- getSiblingMenuObjids(): 같은 부모를 가진 형제 메뉴 OBJID 조회
- 채번규칙 우선순위: menu (형제) > table > global
- 사용자 메뉴(menu_type='1') 레벨 2만 부모 메뉴로 선택 가능

📝 다음 단계:
- 카테고리 컴포넌트도 메뉴 스코프로 전환 예정
This commit is contained in:
kjs 2025-11-11 14:32:00 +09:00
parent 532c80a86b
commit 668b45d4ea
16 changed files with 1838 additions and 268 deletions

View File

@ -27,12 +27,24 @@ router.get("/available/:menuObjid?", authenticateToken, async (req: Authenticate
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined; const menuObjid = req.params.menuObjid ? parseInt(req.params.menuObjid) : undefined;
logger.info("📥 메뉴별 채번 규칙 조회 요청", { companyCode, menuObjid });
try { try {
const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid); const rules = await numberingRuleService.getAvailableRulesForMenu(companyCode, menuObjid);
logger.info("✅ 메뉴별 채번 규칙 조회 성공 (컨트롤러)", {
companyCode,
menuObjid,
rulesCount: rules.length
});
return res.json({ success: true, data: rules }); return res.json({ success: true, data: rules });
} catch (error: any) { } catch (error: any) {
logger.error("메뉴별 사용 가능한 규칙 조회 실패", { logger.error("메뉴별 사용 가능한 규칙 조회 실패 (컨트롤러)", {
error: error.message, error: error.message,
errorCode: error.code,
errorStack: error.stack,
companyCode,
menuObjid, menuObjid,
}); });
return res.status(500).json({ success: false, error: error.message }); return res.status(500).json({ success: false, error: error.message });

View File

@ -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) => { export const getCategoryValues = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const { tableName, columnName } = req.params; const { tableName, columnName } = req.params;
const includeInactive = req.query.includeInactive === "true"; 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( const values = await tableCategoryValueService.getCategoryValues(
tableName, tableName,
columnName, columnName,
companyCode, companyCode,
includeInactive includeInactive,
menuObjid // ← menuObjid 전달
); );
return res.json({ 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) => { export const addCategoryValue = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const companyCode = req.user!.companyCode; const companyCode = req.user!.companyCode;
const userId = req.user!.userId; 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( const newValue = await tableCategoryValueService.addCategoryValue(
value, value,
companyCode, companyCode,
userId userId,
Number(menuObjid) // ← menuObjid 전달
); );
return res.status(201).json({ return res.status(201).json({

View File

@ -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<number[]> {
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<number[]> {
if (!menuObjids || menuObjids.length === 0) {
logger.warn("getAllSiblingMenuObjids: 빈 배열 입력");
return [];
}
const allSiblings = new Set<number>();
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<any | null> {
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;
}
}

View File

@ -4,6 +4,7 @@
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService";
interface NumberingRulePart { interface NumberingRulePart {
id?: number; id?: number;
@ -150,22 +151,33 @@ class NumberingRuleService {
} }
/** /**
* * ( )
*
* :
* - menuObjid가
* - 우선순위: menu ( ) > table > global
*/ */
async getAvailableRulesForMenu( async getAvailableRulesForMenu(
companyCode: string, companyCode: string,
menuObjid?: number menuObjid?: number
): Promise<NumberingRuleConfig[]> { ): Promise<NumberingRuleConfig[]> {
try { try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
companyCode, companyCode,
menuObjid, menuObjid,
}); });
const pool = getPool(); const pool = getPool();
// 1. 형제 메뉴 OBJID 조회
let siblingObjids: number[] = [];
if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
}
// menuObjid가 없으면 global 규칙만 반환 // menuObjid가 없으면 global 규칙만 반환
if (!menuObjid) { if (!menuObjid || siblingObjids.length === 0) {
let query: string; let query: string;
let params: any[]; let params: any[];
@ -261,35 +273,13 @@ class NumberingRuleService {
return result.rows; return result.rows;
} }
// 현재 메뉴의 상위 계층 조회 (2레벨 메뉴 찾기) // 2. 메뉴 스코프: 형제 메뉴의 채번 규칙 조회
const menuHierarchyQuery = ` // 우선순위: menu (형제 메뉴) > table > global
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;
// 사용 가능한 규칙 조회 (멀티테넌시 적용)
let query: string; let query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -309,12 +299,20 @@ class NumberingRuleService {
FROM numbering_rules FROM numbering_rules
WHERE WHERE
scope_type = 'global' scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = $1) OR scope_type = 'table'
ORDER BY scope_type DESC, created_at DESC 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 { } else {
// 일반 회사: 자신의 규칙만 조회 // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -335,58 +333,91 @@ class NumberingRuleService {
WHERE company_code = $1 WHERE company_code = $1
AND ( AND (
scope_type = 'global' 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); const result = await pool.query(query, params);
logger.info("✅ 채번 규칙 쿼리 성공", { rowCount: result.rows.length });
// 파트 정보 추가 // 파트 정보 추가
for (const rule of result.rows) { for (const rule of result.rows) {
let partsQuery: string; try {
let partsParams: any[]; let partsQuery: string;
let partsParams: any[];
if (companyCode === "*") { if (companyCode === "*") {
partsQuery = ` partsQuery = `
SELECT SELECT
id, id,
part_order AS "order", part_order AS "order",
part_type AS "partType", part_type AS "partType",
generation_method AS "generationMethod", generation_method AS "generationMethod",
auto_config AS "autoConfig", auto_config AS "autoConfig",
manual_config AS "manualConfig" manual_config AS "manualConfig"
FROM numbering_rule_parts FROM numbering_rule_parts
WHERE rule_id = $1 WHERE rule_id = $1
ORDER BY part_order ORDER BY part_order
`; `;
partsParams = [rule.ruleId]; partsParams = [rule.ruleId];
} else { } else {
partsQuery = ` partsQuery = `
SELECT SELECT
id, id,
part_order AS "order", part_order AS "order",
part_type AS "partType", part_type AS "partType",
generation_method AS "generationMethod", generation_method AS "generationMethod",
auto_config AS "autoConfig", auto_config AS "autoConfig",
manual_config AS "manualConfig" manual_config AS "manualConfig"
FROM numbering_rule_parts FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2 WHERE rule_id = $1 AND company_code = $2
ORDER BY part_order ORDER BY part_order
`; `;
partsParams = [rule.ruleId, companyCode]; partsParams = [rule.ruleId, companyCode];
}
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;
} }
const partsResult = await pool.query(partsQuery, partsParams);
rule.parts = partsResult.rows;
} }
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode, companyCode,
menuObjid, menuObjid,
level2MenuObjid, siblingCount: siblingObjids.length,
count: result.rowCount, count: result.rowCount,
}); });
@ -394,8 +425,11 @@ class NumberingRuleService {
} catch (error: any) { } catch (error: any) {
logger.error("메뉴별 채번 규칙 조회 실패", { logger.error("메뉴별 채번 규칙 조회 실패", {
error: error.message, error: error.message,
errorCode: error.code,
errorStack: error.stack,
companyCode, companyCode,
menuObjid, menuObjid,
siblingObjids: siblingObjids || [],
}); });
throw error; throw error;
} }

View File

@ -1,5 +1,6 @@
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService";
import { import {
TableCategoryValue, TableCategoryValue,
CategoryColumn, CategoryColumn,
@ -79,84 +80,164 @@ class TableCategoryValueService {
} }
/** /**
* ( ) * ( )
*
* :
* - menuObjid가
* - menuObjid가 ( )
*/ */
async getCategoryValues( async getCategoryValues(
tableName: string, tableName: string,
columnName: string, columnName: string,
companyCode: string, companyCode: string,
includeInactive: boolean = false includeInactive: boolean = false,
menuObjid?: number
): Promise<TableCategoryValue[]> { ): Promise<TableCategoryValue[]> {
try { try {
logger.info("카테고리 값 목록 조회", { logger.info("카테고리 값 목록 조회 (메뉴 스코프)", {
tableName, tableName,
columnName, columnName,
companyCode, companyCode,
includeInactive, includeInactive,
menuObjid,
}); });
const pool = getPool(); 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 query: string;
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 카테고리 값 조회 // 최고 관리자: 모든 카테고리 값 조회
query = ` if (menuObjid && siblingObjids.length > 0) {
SELECT // 메뉴 스코프 적용
value_id AS "valueId", query = `
table_name AS "tableName", SELECT
column_name AS "columnName", value_id AS "valueId",
value_code AS "valueCode", table_name AS "tableName",
value_label AS "valueLabel", column_name AS "columnName",
value_order AS "valueOrder", value_code AS "valueCode",
parent_value_id AS "parentValueId", value_label AS "valueLabel",
depth, value_order AS "valueOrder",
description, parent_value_id AS "parentValueId",
color, depth,
icon, description,
is_active AS "isActive", color,
is_default AS "isDefault", icon,
company_code AS "companyCode", is_active AS "isActive",
created_at AS "createdAt", is_default AS "isDefault",
updated_at AS "updatedAt", company_code AS "companyCode",
created_by AS "createdBy", menu_objid AS "menuObjid",
updated_by AS "updatedBy" created_at AS "createdAt",
FROM table_column_category_values updated_at AS "updatedAt",
WHERE table_name = $1 created_by AS "createdBy",
AND column_name = $2 updated_by AS "updatedBy"
`; FROM table_column_category_values
params = [tableName, columnName]; 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("최고 관리자 카테고리 값 조회"); logger.info("최고 관리자 카테고리 값 조회");
} else { } else {
// 일반 회사: 자신의 카테고리 값만 조회 // 일반 회사: 자신의 카테고리 값만 조회
query = ` if (menuObjid && siblingObjids.length > 0) {
SELECT // 메뉴 스코프 적용
value_id AS "valueId", query = `
table_name AS "tableName", SELECT
column_name AS "columnName", value_id AS "valueId",
value_code AS "valueCode", table_name AS "tableName",
value_label AS "valueLabel", column_name AS "columnName",
value_order AS "valueOrder", value_code AS "valueCode",
parent_value_id AS "parentValueId", value_label AS "valueLabel",
depth, value_order AS "valueOrder",
description, parent_value_id AS "parentValueId",
color, depth,
icon, description,
is_active AS "isActive", color,
is_default AS "isDefault", icon,
company_code AS "companyCode", is_active AS "isActive",
created_at AS "createdAt", is_default AS "isDefault",
updated_at AS "updatedAt", company_code AS "companyCode",
created_by AS "createdBy", menu_objid AS "menuObjid",
updated_by AS "updatedBy" created_at AS "createdAt",
FROM table_column_category_values updated_at AS "updatedAt",
WHERE table_name = $1 created_by AS "createdBy",
AND column_name = $2 updated_by AS "updatedBy"
AND company_code = $3 FROM table_column_category_values
`; WHERE table_name = $1
params = [tableName, columnName, companyCode]; 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 }); logger.info("회사별 카테고리 값 조회", { companyCode });
} }
@ -175,6 +256,8 @@ class TableCategoryValueService {
tableName, tableName,
columnName, columnName,
companyCode, companyCode,
menuObjid,
scopeType: menuObjid ? "menu" : "table",
}); });
return values; return values;
@ -185,17 +268,31 @@ class TableCategoryValueService {
} }
/** /**
* * ( )
*
* @param value
* @param companyCode
* @param userId ID
* @param menuObjid OBJID ()
*/ */
async addCategoryValue( async addCategoryValue(
value: TableCategoryValue, value: TableCategoryValue,
companyCode: string, companyCode: string,
userId: string userId: string,
menuObjid: number
): Promise<TableCategoryValue> { ): Promise<TableCategoryValue> {
const pool = getPool(); const pool = getPool();
try { try {
// 중복 코드 체크 (멀티테넌시 적용) logger.info("카테고리 값 추가 (메뉴 스코프)", {
tableName: value.tableName,
columnName: value.columnName,
valueCode: value.valueCode,
menuObjid,
companyCode,
});
// 중복 코드 체크 (멀티테넌시 + 메뉴 스코프)
let duplicateQuery: string; let duplicateQuery: string;
let duplicateParams: any[]; let duplicateParams: any[];
@ -207,8 +304,9 @@ class TableCategoryValueService {
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND value_code = $3 AND value_code = $3
AND menu_objid = $4
`; `;
duplicateParams = [value.tableName, value.columnName, value.valueCode]; duplicateParams = [value.tableName, value.columnName, value.valueCode, menuObjid];
} else { } else {
// 일반 회사: 자신의 회사에서만 중복 체크 // 일반 회사: 자신의 회사에서만 중복 체크
duplicateQuery = ` duplicateQuery = `
@ -217,9 +315,10 @@ class TableCategoryValueService {
WHERE table_name = $1 WHERE table_name = $1
AND column_name = $2 AND column_name = $2
AND value_code = $3 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); const duplicateResult = await pool.query(duplicateQuery, duplicateParams);
@ -232,8 +331,8 @@ class TableCategoryValueService {
INSERT INTO table_column_category_values ( INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label, value_order, table_name, column_name, value_code, value_label, value_order,
parent_value_id, depth, description, color, icon, parent_value_id, depth, description, color, icon,
is_active, is_default, company_code, created_by 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) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING RETURNING
value_id AS "valueId", value_id AS "valueId",
table_name AS "tableName", table_name AS "tableName",
@ -249,6 +348,7 @@ class TableCategoryValueService {
is_active AS "isActive", is_active AS "isActive",
is_default AS "isDefault", is_default AS "isDefault",
company_code AS "companyCode", company_code AS "companyCode",
menu_objid AS "menuObjid",
created_at AS "createdAt", created_at AS "createdAt",
created_by AS "createdBy" created_by AS "createdBy"
`; `;
@ -267,6 +367,7 @@ class TableCategoryValueService {
value.isActive !== false, value.isActive !== false,
value.isDefault || false, value.isDefault || false,
companyCode, companyCode,
menuObjid, // ← 메뉴 OBJID 저장
userId, userId,
]); ]);
@ -274,6 +375,7 @@ class TableCategoryValueService {
valueId: result.rows[0].valueId, valueId: result.rows[0].valueId,
tableName: value.tableName, tableName: value.tableName,
columnName: value.columnName, columnName: value.columnName,
menuObjid,
}); });
return result.rows[0]; return result.rows[0];

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useParams } from "next/navigation"; import { useParams, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
@ -21,9 +21,13 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감
export default function ScreenViewPage() { export default function ScreenViewPage() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const screenId = parseInt(params.screenId as string); const screenId = parseInt(params.screenId as string);
// URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프)
const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined;
// 🆕 현재 로그인한 사용자 정보 // 🆕 현재 로그인한 사용자 정보
const { user, userName, companyCode } = useAuth(); const { user, userName, companyCode } = useAuth();
@ -404,6 +408,7 @@ export default function ScreenViewPage() {
userId={user?.userId} userId={user?.userId}
userName={userName} userName={userName}
companyCode={companyCode} companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
sortBy={tableSortBy} sortBy={tableSortBy}
sortOrder={tableSortOrder} sortOrder={tableSortOrder}

View File

@ -26,6 +26,7 @@ interface NumberingRuleDesignerProps {
isPreview?: boolean; isPreview?: boolean;
className?: string; className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
} }
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -36,6 +37,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
isPreview = false, isPreview = false,
className = "", className = "",
currentTableName, currentTableName,
menuObjid,
}) => { }) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]); const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null); const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
@ -53,7 +55,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const loadRules = useCallback(async () => { const loadRules = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getNumberingRules(); const response = await getNumberingRules(menuObjid);
if (response.success && response.data) { if (response.success && response.data) {
setSavedRules(response.data); setSavedRules(response.data);
} else { } else {
@ -64,7 +66,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [menuObjid]);
useEffect(() => { useEffect(() => {
if (currentRule) { if (currentRule) {
@ -145,7 +147,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
"currentRule.tableName": currentRule.tableName, "currentRule.tableName": currentRule.tableName,
"ruleToSave.tableName": ruleToSave.tableName, "ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.scopeType": ruleToSave.scopeType, "ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave ruleToSave,
}); });
let response; let response;
@ -273,7 +275,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
savedRules.map((rule) => ( savedRules.map((rule) => (
<Card <Card
key={rule.ruleId} key={rule.ruleId}
className={`py-2 border-border hover:bg-accent cursor-pointer transition-colors ${ className={`border-border hover:bg-accent cursor-pointer py-2 transition-colors ${
selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card" selectedRuleId === rule.ruleId ? "border-primary bg-primary/5" : "bg-card"
}`} }`}
onClick={() => handleSelectRule(rule)} onClick={() => handleSelectRule(rule)}
@ -356,7 +358,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{currentTableName && ( {currentTableName && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-medium"> </Label> <Label className="text-sm font-medium"> </Label>
<div className="flex h-9 items-center rounded-md border border-input bg-muted px-3 text-sm text-muted-foreground"> <div className="border-input bg-muted text-muted-foreground flex h-9 items-center rounded-md border px-3 text-sm">
{currentTableName} {currentTableName}
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">

View File

@ -41,6 +41,7 @@ interface RealtimePreviewProps {
userId?: string; // 🆕 현재 사용자 ID userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름 userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드 companyCode?: string; // 🆕 현재 사용자의 회사 코드
menuObjid?: number; // 🆕 현재 메뉴 OBJID (메뉴 스코프)
selectedRowsData?: any[]; selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
flowSelectedData?: any[]; flowSelectedData?: any[];
@ -107,6 +108,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
userId, // 🆕 사용자 ID userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름 userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드 companyCode, // 🆕 회사 코드
menuObjid, // 🆕 메뉴 OBJID
selectedRowsData, selectedRowsData,
onSelectedRowsChange, onSelectedRowsChange,
flowSelectedData, flowSelectedData,
@ -344,6 +346,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
userId={userId} userId={userId}
userName={userName} userName={userName}
companyCode={companyCode} companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange} onSelectedRowsChange={onSelectedRowsChange}
flowSelectedData={flowSelectedData} flowSelectedData={flowSelectedData}

View File

@ -14,7 +14,7 @@ interface TextTypeConfigPanelProps {
config: TextTypeConfig; config: TextTypeConfig;
onConfigChange: (config: TextTypeConfig) => void; onConfigChange: (config: TextTypeConfig) => void;
tableName?: string; // 화면의 테이블명 (선택) tableName?: string; // 화면의 테이블명 (선택)
menuObjid?: number; // 메뉴 objid (선택) menuObjid?: number; // 메뉴 objid (선택) - 사용자가 선택한 부모 메뉴
} }
export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
@ -45,6 +45,10 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]); const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false); const [loadingRules, setLoadingRules] = useState(false);
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(menuObjid);
// 로컬 상태로 실시간 입력 관리 // 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({ const [localValues, setLocalValues] = useState({
minLength: safeConfig.minLength?.toString() || "", minLength: safeConfig.minLength?.toString() || "",
@ -60,31 +64,61 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
numberingRuleId: safeConfig.numberingRuleId, 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(() => { useEffect(() => {
const loadRules = async () => { const loadRules = async () => {
console.log("🔄 채번 규칙 로드 시작:", { console.log("🔄 채번 규칙 로드 시작:", {
autoValueType: localValues.autoValueType, autoValueType: localValues.autoValueType,
selectedMenuObjid,
tableName, tableName,
hasTableName: !!tableName,
}); });
// 메뉴를 선택하지 않으면 로드하지 않음
if (!selectedMenuObjid) {
console.warn("⚠️ 메뉴를 선택해야 채번 규칙을 조회할 수 있습니다");
setNumberingRules([]);
return;
}
setLoadingRules(true); setLoadingRules(true);
try { try {
let response; // 선택된 메뉴의 채번 규칙 조회 (메뉴 스코프)
console.log("📋 메뉴 기반 채번 규칙 조회 API 호출:", { menuObjid: selectedMenuObjid });
// 테이블명이 있으면 테이블 기반 필터링 사용 const response = await getAvailableNumberingRules(selectedMenuObjid);
if (tableName) { console.log("📋 API 응답:", response);
console.log("📋 테이블 기반 채번 규칙 조회 API 호출:", { tableName });
response = await getAvailableNumberingRulesForScreen(tableName);
console.log("📋 API 응답:", response);
} else {
// 테이블명이 없으면 빈 배열 (테이블 필수)
console.warn("⚠️ 테이블명이 없어 채번 규칙을 조회할 수 없습니다");
setNumberingRules([]);
setLoadingRules(false);
return;
}
if (response.success && response.data) { if (response.success && response.data) {
setNumberingRules(response.data); setNumberingRules(response.data);
@ -93,7 +127,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
rules: response.data.map((r: any) => ({ rules: response.data.map((r: any) => ({
ruleId: r.ruleId, ruleId: r.ruleId,
ruleName: r.ruleName, ruleName: r.ruleName,
tableName: r.tableName, menuObjid: selectedMenuObjid,
})), })),
}); });
} else { } else {
@ -115,7 +149,7 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
} else { } else {
console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType); console.log("⏭️ autoValueType !== 'numbering_rule', 규칙 로드 스킵:", localValues.autoValueType);
} }
}, [localValues.autoValueType, tableName]); }, [localValues.autoValueType, selectedMenuObjid]);
// config가 변경될 때 로컬 상태 동기화 // config가 변경될 때 로컬 상태 동기화
useEffect(() => { useEffect(() => {
@ -314,37 +348,95 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
</Select> </Select>
</div> </div>
{(() => {
console.log("🔍 메뉴 선택 UI 렌더링 체크:", {
autoValueType: localValues.autoValueType,
isNumberingRule: localValues.autoValueType === "numbering_rule",
parentMenusCount: parentMenus.length,
selectedMenuObjid,
});
return null;
})()}
{localValues.autoValueType === "numbering_rule" && ( {localValues.autoValueType === "numbering_rule" && (
<div> <>
<Label htmlFor="numberingRuleId" className="text-sm font-medium"> {/* 부모 메뉴 선택 */}
<span className="text-destructive">*</span> <div>
</Label> <Label htmlFor="parentMenu" className="text-sm font-medium">
<Select <span className="text-destructive">*</span>
value={localValues.numberingRuleId} </Label>
onValueChange={(value) => updateConfig("numberingRuleId", value)} <Select
disabled={loadingRules} value={selectedMenuObjid?.toString() || ""}
> onValueChange={(value) => setSelectedMenuObjid(parseInt(value))}
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}> >
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} /> <SelectTrigger className="mt-1 h-8 w-full px-2 py-0 text-xs">
</SelectTrigger> <SelectValue placeholder="채번 규칙을 사용할 메뉴 선택" />
<SelectContent> </SelectTrigger>
{numberingRules.length === 0 ? ( <SelectContent>
<SelectItem value="no-rules" disabled> {parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName} ({rule.ruleId})
</SelectItem> </SelectItem>
)) ) : (
)} parentMenus.map((menu) => {
</SelectContent> const objid = menu.objid || menu.OBJID;
</Select> const menuName = menu.menu_name_kor || menu.MENU_NAME_KOR;
<p className="text-muted-foreground mt-1 text-[10px]"> return (
<SelectItem key={objid} value={objid.toString()}>
</p> {menuName}
</div> </SelectItem>
);
})
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
{/* 채번 규칙 선택 */}
<div>
<Label htmlFor="numberingRuleId" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={localValues.numberingRuleId}
onValueChange={(value) => updateConfig("numberingRuleId", value)}
disabled={loadingRules || !selectedMenuObjid}
>
<SelectTrigger className="mt-1 h-8 w-full px-2 py-0 text-xs">
<SelectValue
placeholder={
!selectedMenuObjid
? "먼저 메뉴를 선택하세요"
: loadingRules
? "규칙 로딩 중..."
: "채번 규칙 선택"
}
/>
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
{!selectedMenuObjid
? "메뉴를 먼저 선택하세요"
: "사용 가능한 규칙이 없습니다"}
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName} ({rule.ruleId})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
</>
)} )}
{localValues.autoValueType === "custom" && ( {localValues.autoValueType === "custom" && (

View File

@ -8,14 +8,15 @@ import { GripVertical } from "lucide-react";
interface CategoryWidgetProps { interface CategoryWidgetProps {
widgetId: string; widgetId: string;
tableName: string; // 현재 화면의 테이블 tableName: string; // 현재 화면의 테이블
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
} }
/** /**
* ( ) * ( )
* - 좌측: 현재 * - 좌측: 현재
* - 우측: 선택된 ( ) * - 우측: 선택된 ( )
*/ */
export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) { export function CategoryWidget({ widgetId, tableName, menuObjid }: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<{ const [selectedColumn, setSelectedColumn] = useState<{
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
@ -69,6 +70,7 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
onColumnSelect={(columnName, columnLabel) => onColumnSelect={(columnName, columnLabel) =>
setSelectedColumn({ columnName, columnLabel }) setSelectedColumn({ columnName, columnLabel })
} }
menuObjid={menuObjid}
/> />
</div> </div>
@ -87,6 +89,7 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
tableName={tableName} tableName={tableName}
columnName={selectedColumn.columnName} columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel} columnLabel={selectedColumn.columnLabel}
menuObjid={menuObjid}
/> />
) : ( ) : (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">

View File

@ -16,13 +16,14 @@ interface CategoryColumnListProps {
tableName: string; tableName: string;
selectedColumn: string | null; selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void; 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<CategoryColumn[]>([]); const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -89,7 +90,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }
// 각 컬럼의 값 개수 가져오기 // 각 컬럼의 값 개수 가져오기
let valueCount = 0; let valueCount = 0;
try { try {
const valuesResult = await getCategoryValues(tableName, colName, false); const valuesResult = await getCategoryValues(tableName, colName, false, menuObjid);
if (valuesResult.success && valuesResult.data) { if (valuesResult.success && valuesResult.data) {
valueCount = valuesResult.data.length; valueCount = valuesResult.data.length;
} }

View File

@ -29,6 +29,7 @@ interface CategoryValueManagerProps {
columnName: string; columnName: string;
columnLabel: string; columnLabel: string;
onValueCountChange?: (count: number) => void; onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
} }
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
@ -36,6 +37,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
columnName, columnName,
columnLabel, columnLabel,
onValueCountChange, onValueCountChange,
menuObjid,
}) => { }) => {
const { toast } = useToast(); const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]); const [values, setValues] = useState<TableCategoryValue[]>([]);
@ -81,7 +83,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
setIsLoading(true); setIsLoading(true);
try { try {
// includeInactive: true로 비활성 값도 포함 // includeInactive: true로 비활성 값도 포함
const response = await getCategoryValues(tableName, columnName, true); const response = await getCategoryValues(tableName, columnName, true, menuObjid);
if (response.success && response.data) { if (response.success && response.data) {
setValues(response.data); setValues(response.data);
setFilteredValues(response.data); setFilteredValues(response.data);
@ -101,11 +103,23 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
const handleAddValue = async (newValue: TableCategoryValue) => { const handleAddValue = async (newValue: TableCategoryValue) => {
try { try {
const response = await addCategoryValue({ if (!menuObjid) {
...newValue, toast({
tableName, title: "오류",
columnName, description: "메뉴 정보가 없습니다. 카테고리 값을 추가할 수 없습니다.",
}); variant: "destructive",
});
return;
}
const response = await addCategoryValue(
{
...newValue,
tableName,
columnName,
},
menuObjid
);
if (response.success && response.data) { if (response.success && response.data) {
await loadCategoryValues(); await loadCategoryValues();
@ -128,7 +142,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
title: "오류", title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다", description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive", variant: "destructive",
}); });
} }
}; };

View File

@ -21,19 +21,30 @@ export async function getCategoryColumns(tableName: string) {
} }
/** /**
* ( ) * ( )
*
* @param tableName
* @param columnName
* @param includeInactive
* @param menuObjid OBJID (, )
*/ */
export async function getCategoryValues( export async function getCategoryValues(
tableName: string, tableName: string,
columnName: string, columnName: string,
includeInactive: boolean = false includeInactive: boolean = false,
menuObjid?: number
) { ) {
try { try {
const params: any = { includeInactive };
if (menuObjid) {
params.menuObjid = menuObjid;
}
const response = await apiClient.get<{ const response = await apiClient.get<{
success: boolean; success: boolean;
data: TableCategoryValue[]; data: TableCategoryValue[];
}>(`/table-categories/${tableName}/${columnName}/values`, { }>(`/table-categories/${tableName}/${columnName}/values`, {
params: { includeInactive }, params,
}); });
return response.data; return response.data;
} catch (error: any) { } 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 { try {
const response = await apiClient.post<{ const response = await apiClient.post<{
success: boolean; success: boolean;
data: TableCategoryValue; data: TableCategoryValue;
}>("/table-categories/values", value); }>("/table-categories/values", {
...value,
menuObjid, // ← menuObjid 포함
});
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
console.error("카테고리 값 추가 실패:", error); console.error("카테고리 값 추가 실패:", error);

View File

@ -98,6 +98,7 @@ export interface DynamicComponentRendererProps {
screenId?: number; screenId?: number;
tableName?: string; tableName?: string;
menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요) menuId?: number; // 🆕 메뉴 ID (카테고리 관리 등에 필요)
menuObjid?: number; // 🆕 메뉴 OBJID (메뉴 스코프 - 카테고리/채번)
selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용) selectedScreen?: any; // 🆕 화면 정보 전체 (menuId 등 추출용)
userId?: string; // 🆕 현재 사용자 ID userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름 userName?: string; // 🆕 현재 사용자 이름
@ -224,6 +225,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onFormDataChange, onFormDataChange,
tableName, tableName,
menuId, // 🆕 메뉴 ID menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보 selectedScreen, // 🆕 화면 정보
onRefresh, onRefresh,
onClose, onClose,
@ -319,6 +321,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onChange: handleChange, // 개선된 onChange 핸들러 전달 onChange: handleChange, // 개선된 onChange 핸들러 전달
tableName, tableName,
menuId, // 🆕 메뉴 ID menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보 selectedScreen, // 🆕 화면 정보
onRefresh, onRefresh,
onClose, onClose,

View File

@ -15,32 +15,65 @@ export interface TextInputConfigPanelProps {
config: TextInputConfig; config: TextInputConfig;
onChange: (config: Partial<TextInputConfig>) => void; onChange: (config: Partial<TextInputConfig>) => void;
screenTableName?: string; // 🆕 현재 화면의 테이블명 screenTableName?: string; // 🆕 현재 화면의 테이블명
menuObjid?: number; // 🆕 메뉴 OBJID (사용자 선택)
} }
/** /**
* TextInput * TextInput
* UI * UI
*/ */
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName }) => { export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange, screenTableName, menuObjid }) => {
// 채번 규칙 목록 상태 // 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]); const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false); const [loadingRules, setLoadingRules] = useState(false);
// 채번 규칙 목록 로드 // 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(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(() => { useEffect(() => {
const loadRules = async () => { const loadRules = async () => {
// 메뉴가 선택되지 않았으면 로드하지 않음
if (!selectedMenuObjid) {
console.log("⚠️ 메뉴가 선택되지 않아 채번 규칙을 로드하지 않습니다");
setNumberingRules([]);
return;
}
setLoadingRules(true); setLoadingRules(true);
try { try {
let response; console.log("🔍 선택된 메뉴 기반 채번 규칙 로드", { selectedMenuObjid });
const response = await getAvailableNumberingRules(selectedMenuObjid);
// 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회
if (screenTableName) {
console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName });
response = await getAvailableNumberingRulesForScreen(screenTableName);
} else {
console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)");
response = await getAvailableNumberingRules();
}
if (response.success && response.data) { if (response.success && response.data) {
setNumberingRules(response.data); setNumberingRules(response.data);
@ -48,6 +81,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
} }
} catch (error) { } catch (error) {
console.error("채번 규칙 목록 로드 실패:", error); console.error("채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally { } finally {
setLoadingRules(false); setLoadingRules(false);
} }
@ -57,7 +91,7 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
if (config.autoGeneration?.type === "numbering_rule") { if (config.autoGeneration?.type === "numbering_rule") {
loadRules(); loadRules();
} }
}, [config.autoGeneration?.type, screenTableName]); }, [config.autoGeneration?.type, selectedMenuObjid]);
const handleChange = (key: keyof TextInputConfig, value: any) => { const handleChange = (key: keyof TextInputConfig, value: any) => {
onChange({ [key]: value }); onChange({ [key]: value });
@ -157,50 +191,100 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
{/* 채번 규칙 선택 */} {/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && ( {config.autoGeneration?.type === "numbering_rule" && (
<div className="space-y-2"> <>
<Label htmlFor="numberingRuleId"> {/* 부모 메뉴 선택 */}
<span className="text-destructive">*</span> <div className="space-y-2">
</Label> <Label htmlFor="targetMenu">
<Select <span className="text-destructive">*</span>
value={config.autoGeneration?.options?.numberingRuleId || ""} </Label>
onValueChange={(value) => { <Select
const currentConfig = config.autoGeneration!; value={selectedMenuObjid?.toString() || ""}
handleChange("autoGeneration", { onValueChange={(value) => {
...currentConfig, const menuObjid = parseInt(value);
options: { setSelectedMenuObjid(menuObjid);
...currentConfig.options, console.log("✅ 메뉴 선택됨:", menuObjid);
numberingRuleId: value, }}
}, disabled={loadingMenus}
}); >
}} <SelectTrigger>
disabled={loadingRules} <SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
> </SelectTrigger>
<SelectTrigger> <SelectContent>
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} /> {parentMenus.length === 0 ? (
</SelectTrigger> <SelectItem value="no-menus" disabled>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName}
{rule.description && (
<span className="text-muted-foreground ml-2 text-xs">
- {rule.description}
</span>
)}
</SelectItem> </SelectItem>
)) ) : (
)} parentMenus.map((menu) => (
</SelectContent> <SelectItem key={menu.objid} value={menu.objid.toString()}>
</Select> {menu.menu_name_kor}
<p className="text-muted-foreground text-xs"> {menu.menu_name_eng && (
<span className="text-muted-foreground ml-2 text-xs">
</p> ({menu.menu_name_eng})
</div> </span>
)}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
( )
</p>
</div>
{/* 채번 규칙 선택 (메뉴 선택 후) */}
{selectedMenuObjid ? (
<div className="space-y-2">
<Label htmlFor="numberingRuleId">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.autoGeneration?.options?.numberingRuleId || ""}
onValueChange={(value) => {
const currentConfig = config.autoGeneration!;
handleChange("autoGeneration", {
...currentConfig,
options: {
...currentConfig.options,
numberingRuleId: value,
},
});
}}
disabled={loadingRules}
>
<SelectTrigger>
<SelectValue placeholder={loadingRules ? "규칙 로딩 중..." : "채번 규칙 선택"} />
</SelectTrigger>
<SelectContent>
{numberingRules.length === 0 ? (
<SelectItem value="no-rules" disabled>
</SelectItem>
) : (
numberingRules.map((rule) => (
<SelectItem key={rule.ruleId} value={rule.ruleId}>
{rule.ruleName}
{rule.description && (
<span className="text-muted-foreground ml-2 text-xs">
- {rule.description}
</span>
)}
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
</div>
)}
</>
)} )}
</div> </div>
)} )}

File diff suppressed because it is too large Load Diff