Merge pull request 'feature/screen-management' (#199) from feature/screen-management into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/199
This commit is contained in:
kjs 2025-11-11 18:33:39 +09:00
commit 379a3852b6
41 changed files with 4062 additions and 592 deletions

View File

@ -20,8 +20,9 @@ export class CommonCodeController {
*/
async getCategories(req: AuthenticatedRequest, res: Response) {
try {
const { search, isActive, page = "1", size = "20" } = req.query;
const { search, isActive, page = "1", size = "20", menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const categories = await this.commonCodeService.getCategories(
{
@ -35,7 +36,8 @@ export class CommonCodeController {
page: parseInt(page as string),
size: parseInt(size as string),
},
userCompanyCode
userCompanyCode,
menuObjidNum
);
return res.json({
@ -61,8 +63,9 @@ export class CommonCodeController {
async getCodes(req: AuthenticatedRequest, res: Response) {
try {
const { categoryCode } = req.params;
const { search, isActive, page, size } = req.query;
const { search, isActive, page, size, menuObjid } = req.query;
const userCompanyCode = req.user?.companyCode;
const menuObjidNum = menuObjid ? Number(menuObjid) : undefined;
const result = await this.commonCodeService.getCodes(
categoryCode,
@ -77,7 +80,8 @@ export class CommonCodeController {
page: page ? parseInt(page as string) : undefined,
size: size ? parseInt(size as string) : undefined,
},
userCompanyCode
userCompanyCode,
menuObjidNum
);
// 프론트엔드가 기대하는 형식으로 데이터 변환
@ -131,6 +135,7 @@ export class CommonCodeController {
const categoryData: CreateCategoryData = req.body;
const userId = req.user?.userId || "SYSTEM";
const companyCode = req.user?.companyCode || "*";
const menuObjid = req.body.menuObjid;
// 입력값 검증
if (!categoryData.categoryCode || !categoryData.categoryName) {
@ -140,10 +145,18 @@ export class CommonCodeController {
});
}
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID는 필수입니다.",
});
}
const category = await this.commonCodeService.createCategory(
categoryData,
userId,
companyCode
companyCode,
Number(menuObjid)
);
return res.status(201).json({
@ -263,6 +276,7 @@ export class CommonCodeController {
const codeData: CreateCodeData = req.body;
const userId = req.user?.userId || "SYSTEM";
const companyCode = req.user?.companyCode || "*";
const menuObjid = req.body.menuObjid;
// 입력값 검증
if (!codeData.codeValue || !codeData.codeName) {
@ -272,11 +286,19 @@ export class CommonCodeController {
});
}
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID는 필수입니다.",
});
}
const code = await this.commonCodeService.createCode(
categoryCode,
codeData,
userId,
companyCode
companyCode,
Number(menuObjid)
);
return res.status(201).json({

View File

@ -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("메뉴별 채번 규칙 조회 요청", { menuObjid, companyCode });
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 });
@ -100,6 +112,17 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
const userId = req.user!.userId;
const ruleConfig = req.body;
logger.info("🔍 [POST /numbering-rules] 채번 규칙 생성 요청:", {
companyCode,
userId,
ruleId: ruleConfig.ruleId,
ruleName: ruleConfig.ruleName,
scopeType: ruleConfig.scopeType,
menuObjid: ruleConfig.menuObjid,
tableName: ruleConfig.tableName,
partsCount: ruleConfig.parts?.length,
});
try {
if (!ruleConfig.ruleId || !ruleConfig.ruleName) {
return res.status(400).json({ success: false, error: "규칙 ID와 규칙명은 필수입니다" });
@ -110,12 +133,22 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
}
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
ruleId: newRule.ruleId,
menuObjid: newRule.menuObjid,
});
return res.status(201).json({ success: true, data: newRule });
} catch (error: any) {
if (error.code === "23505") {
return res.status(409).json({ success: false, error: "이미 존재하는 규칙 ID입니다" });
}
logger.error("규칙 생성 실패", { error: error.message });
logger.error("❌ [POST /numbering-rules] 규칙 생성 실패:", {
error: error.message,
stack: error.stack,
code: error.code,
});
return res.status(500).json({ success: false, error: error.message });
}
});

View File

@ -60,6 +60,29 @@ export const getScreen = async (
}
};
// 화면에 할당된 메뉴 조회
export const getScreenMenu = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { companyCode } = req.user as any;
const menuInfo = await screenManagementService.getMenuByScreen(
parseInt(id),
companyCode
);
res.json({ success: true, data: menuInfo });
} catch (error) {
console.error("화면 메뉴 조회 실패:", error);
res
.status(500)
.json({ success: false, message: "화면 메뉴 조회에 실패했습니다." });
}
};
// 화면 생성
export const createScreen = async (
req: AuthenticatedRequest,

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) => {
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({

View File

@ -1599,3 +1599,114 @@ export async function toggleLogTable(
res.status(500).json(response);
}
}
/**
*
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description input_type='category'
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
return res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
}
// 1. 형제 메뉴 조회
const { getSiblingMenuObjids } = await import("../services/menuService");
const siblingObjids = await getSiblingMenuObjids(parseInt(menuObjid));
logger.info("✅ 형제 메뉴 조회 완료", { siblingObjids });
// 2. 형제 메뉴들이 사용하는 테이블 조회
const { getPool } = await import("../database/db");
const pool = getPool();
const tablesQuery = `
SELECT DISTINCT sd.table_name
FROM screen_menu_assignments sma
INNER JOIN screen_definitions sd ON sma.screen_id = sd.screen_id
WHERE sma.menu_objid = ANY($1)
AND sma.company_code = $2
AND sd.table_name IS NOT NULL
`;
const tablesResult = await pool.query(tablesQuery, [siblingObjids, companyCode]);
const tableNames = tablesResult.rows.map((row: any) => row.table_name);
logger.info("✅ 형제 메뉴 테이블 조회 완료", { tableNames, count: tableNames.length });
if (tableNames.length === 0) {
return res.json({
success: true,
data: [],
message: "형제 메뉴에 연결된 테이블이 없습니다.",
});
}
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함)
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode });
const columnsQuery = `
SELECT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN column_labels cl
ON ttc.table_name = cl.table_name
AND ttc.column_name = cl.column_name
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name
`;
logger.info("🔍 카테고리 컬럼 쿼리 실행 중...");
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length });
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
});
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패");
logger.error("에러 메시지:", error.message);
logger.error("에러 스택:", error.stack);
logger.error("에러 전체:", error);
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회에 실패했습니다.",
error: error.message,
stack: error.stack, // 디버깅용
});
}
}

View File

@ -3,6 +3,7 @@ import { authenticateToken } from "../middleware/authMiddleware";
import {
getScreens,
getScreen,
getScreenMenu,
createScreen,
updateScreen,
updateScreenInfo,
@ -33,6 +34,7 @@ router.use(authenticateToken);
// 화면 관리
router.get("/screens", getScreens);
router.get("/screens/:id", getScreen);
router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴 조회
router.post("/screens", createScreen);
router.put("/screens/:id", updateScreen);
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정

View File

@ -23,6 +23,7 @@ import {
getLogConfig,
getLogData,
toggleLogTable,
getCategoryColumnsByMenu, // 🆕 메뉴별 카테고리 컬럼 조회
} from "../controllers/tableManagementController";
const router = express.Router();
@ -187,4 +188,14 @@ router.get("/tables/:tableName/log", getLogData);
*/
router.post("/tables/:tableName/log/toggle", toggleLogTable);
// ========================================
// 메뉴 기반 카테고리 관리 API
// ========================================
/**
*
* GET /api/table-management/menu/:menuObjid/category-columns
*/
router.get("/menu/:menuObjid/category-columns", getCategoryColumnsByMenu);
export default router;

View File

@ -66,7 +66,7 @@ export class CommonCodeService {
/**
*
*/
async getCategories(params: GetCategoriesParams, userCompanyCode?: string) {
async getCategories(params: GetCategoriesParams, userCompanyCode?: string, menuObjid?: number) {
try {
const { search, isActive, page = 1, size = 20 } = params;
@ -74,6 +74,16 @@ export class CommonCodeService {
const values: any[] = [];
let paramIndex = 1;
// 메뉴별 필터링 (형제 메뉴 포함)
if (menuObjid) {
const { getSiblingMenuObjids } = await import('./menuService');
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
values.push(siblingMenuObjids);
paramIndex++;
logger.info(`메뉴별 코드 카테고리 필터링: ${menuObjid}, 형제 메뉴: ${siblingMenuObjids.join(', ')}`);
}
// 회사별 필터링 (최고 관리자가 아닌 경우)
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
@ -142,15 +152,43 @@ export class CommonCodeService {
async getCodes(
categoryCode: string,
params: GetCodesParams,
userCompanyCode?: string
userCompanyCode?: string,
menuObjid?: number
) {
try {
const { search, isActive, page = 1, size = 20 } = params;
logger.info(`🔍 [getCodes] 코드 조회 시작:`, {
categoryCode,
menuObjid,
hasMenuObjid: !!menuObjid,
userCompanyCode,
search,
isActive,
page,
size,
});
const whereConditions: string[] = ["code_category = $1"];
const values: any[] = [categoryCode];
let paramIndex = 2;
// 메뉴별 필터링 (형제 메뉴 포함)
if (menuObjid) {
const { getSiblingMenuObjids } = await import('./menuService');
const siblingMenuObjids = await getSiblingMenuObjids(menuObjid);
whereConditions.push(`menu_objid = ANY($${paramIndex})`);
values.push(siblingMenuObjids);
paramIndex++;
logger.info(`📋 [getCodes] 메뉴별 코드 필터링:`, {
menuObjid,
siblingMenuObjids,
siblingCount: siblingMenuObjids.length,
});
} else {
logger.warn(`⚠️ [getCodes] menuObjid 없음 - 전역 코드 조회`);
}
// 회사별 필터링 (최고 관리자가 아닌 경우)
if (userCompanyCode && userCompanyCode !== "*") {
whereConditions.push(`company_code = $${paramIndex}`);
@ -178,6 +216,13 @@ export class CommonCodeService {
const offset = (page - 1) * size;
logger.info(`📝 [getCodes] 실행할 쿼리:`, {
whereClause,
values,
whereConditions,
paramIndex,
});
// 코드 조회
const codes = await query<CodeInfo>(
`SELECT * FROM code_info
@ -196,9 +241,20 @@ export class CommonCodeService {
const total = parseInt(countResult?.count || "0");
logger.info(
`코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"})`
`✅ [getCodes] 코드 조회 완료: ${categoryCode} - ${codes.length}개, 전체: ${total}개 (회사: ${userCompanyCode || "전체"}, menuObjid: ${menuObjid || "없음"})`
);
logger.info(`📊 [getCodes] 조회된 코드 상세:`, {
categoryCode,
menuObjid,
codes: codes.map((c) => ({
code_value: c.code_value,
code_name: c.code_name,
menu_objid: c.menu_objid,
company_code: c.company_code,
})),
});
return { data: codes, total };
} catch (error) {
logger.error(`코드 조회 중 오류 (${categoryCode}):`, error);
@ -212,14 +268,15 @@ export class CommonCodeService {
async createCategory(
data: CreateCategoryData,
createdBy: string,
companyCode: string
companyCode: string,
menuObjid: number
) {
try {
const category = await queryOne<CodeCategory>(
`INSERT INTO code_category
(category_code, category_name, category_name_eng, description, sort_order,
is_active, company_code, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, NOW(), NOW())
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, 'Y', $6, $7, $8, $9, NOW(), NOW())
RETURNING *`,
[
data.categoryCode,
@ -227,6 +284,7 @@ export class CommonCodeService {
data.categoryNameEng || null,
data.description || null,
data.sortOrder || 0,
menuObjid,
companyCode,
createdBy,
createdBy,
@ -234,7 +292,7 @@ export class CommonCodeService {
);
logger.info(
`카테고리 생성 완료: ${data.categoryCode} (회사: ${companyCode})`
`카테고리 생성 완료: ${data.categoryCode} (메뉴: ${menuObjid}, 회사: ${companyCode})`
);
return category;
} catch (error) {
@ -352,14 +410,15 @@ export class CommonCodeService {
categoryCode: string,
data: CreateCodeData,
createdBy: string,
companyCode: string
companyCode: string,
menuObjid: number
) {
try {
const code = await queryOne<CodeInfo>(
`INSERT INTO code_info
(code_category, code_value, code_name, code_name_eng, description, sort_order,
is_active, company_code, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, NOW(), NOW())
is_active, menu_objid, company_code, created_by, updated_by, created_date, updated_date)
VALUES ($1, $2, $3, $4, $5, $6, 'Y', $7, $8, $9, $10, NOW(), NOW())
RETURNING *`,
[
categoryCode,
@ -368,6 +427,7 @@ export class CommonCodeService {
data.codeNameEng || null,
data.description || null,
data.sortOrder || 0,
menuObjid,
companyCode,
createdBy,
createdBy,
@ -375,7 +435,7 @@ export class CommonCodeService {
);
logger.info(
`코드 생성 완료: ${categoryCode}.${data.codeValue} (회사: ${companyCode})`
`코드 생성 완료: ${categoryCode}.${data.codeValue} (메뉴: ${menuObjid}, 회사: ${companyCode})`
);
return code;
} catch (error) {

View File

@ -0,0 +1,147 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
/**
*
*
*
*/
/**
* OBJID
* ( + )
*
* :
* - /
* - (3 )
* - (parent_obj_id = 0)
* -
*
* @param menuObjid OBJID
* @returns + OBJID ( , )
*
* @example
* // 영업관리 (200)
* // ├── 고객관리 (201)
* // │ └── 고객등록 (211)
* // ├── 계약관리 (202)
* // └── 주문관리 (203)
*
* await getSiblingMenuObjids(201);
* // 결과: [201, 202, 203, 211] - 형제(202, 203) + 자식(211)
*/
export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]> {
const pool = getPool();
try {
logger.debug("메뉴 스코프 조회 시작", { menuObjid });
// 1. 현재 메뉴 자신을 포함
const menuObjids = [menuObjid];
// 2. 현재 메뉴의 자식 메뉴들 조회
const childrenQuery = `
SELECT objid FROM menu_info
WHERE parent_obj_id = $1
ORDER BY objid
`;
const childrenResult = await pool.query(childrenQuery, [menuObjid]);
const childObjids = childrenResult.rows.map((row) => Number(row.objid));
// 3. 자신 + 자식을 합쳐서 정렬
const allObjids = Array.from(new Set([...menuObjids, ...childObjids])).sort((a, b) => a - b);
logger.debug("메뉴 스코프 조회 완료", {
menuObjid,
childCount: childObjids.length,
totalCount: allObjids.length
});
return allObjids;
} 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 { 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<NumberingRuleConfig[]> {
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,22 @@ 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))
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- 임시: table menu_objid로
OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: 기존 (menu_objid NULL)
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = 'global' THEN 3
END,
created_at DESC
`;
params = [level2MenuObjid];
params = [siblingObjids];
logger.info("최고 관리자: 형제 메뉴 포함 채번 규칙 조회 (기존 규칙 포함)", { siblingObjids });
} else {
// 일반 회사: 자신의 규칙만 조회
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함)
query = `
SELECT
rule_id AS "ruleId",
@ -335,58 +335,93 @@ 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))
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- 임시: table menu_objid로
OR (scope_type = 'table' AND menu_objid IS NULL) -- 임시: 기존 (menu_objid NULL)
)
ORDER BY scope_type DESC, created_at DESC
ORDER BY
CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($2)) THEN 1
WHEN scope_type = 'table' THEN 2
WHEN scope_type = '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.debug("채번 규칙 쿼리 성공", { ruleCount: 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 +429,11 @@ class NumberingRuleService {
} catch (error: any) {
logger.error("메뉴별 채번 규칙 조회 실패", {
error: error.message,
errorCode: error.code,
errorStack: error.stack,
companyCode,
menuObjid,
siblingObjids: siblingObjids || [],
});
throw error;
}

View File

@ -1,5 +1,5 @@
// ✅ Prisma → Raw Query 전환 (Phase 2.1)
import { query, transaction } from "../database/db";
import { query, queryOne, transaction } from "../database/db";
import {
ScreenDefinition,
CreateScreenRequest,
@ -1547,6 +1547,39 @@ export class ScreenManagementService {
return screens.map((screen) => this.mapToScreenDefinition(screen));
}
/**
* ( )
* menuObjid를
*/
async getMenuByScreen(
screenId: number,
companyCode: string
): Promise<{ menuObjid: number; menuName?: string } | null> {
const result = await queryOne<{
menu_objid: string;
menu_name_kor?: string;
}>(
`SELECT sma.menu_objid, mi.menu_name_kor
FROM screen_menu_assignments sma
LEFT JOIN menu_info mi ON sma.menu_objid = mi.objid
WHERE sma.screen_id = $1
AND sma.company_code = $2
AND sma.is_active = 'Y'
ORDER BY sma.created_date ASC
LIMIT 1`,
[screenId, companyCode]
);
if (!result) {
return null;
}
return {
menuObjid: parseInt(result.menu_objid),
menuName: result.menu_name_kor,
};
}
/**
* - ( Raw Query )
*/

View File

@ -1,5 +1,6 @@
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService";
import {
TableCategoryValue,
CategoryColumn,
@ -62,7 +63,9 @@ class TableCategoryValueService {
logger.info("회사별 카테고리 컬럼 조회", { companyCode });
}
const result = await pool.query(query, [tableName, companyCode]);
// 쿼리 파라미터는 company_code에 따라 다름
const params = companyCode === "*" ? [tableName] : [tableName, companyCode];
const result = await pool.query(query, params);
logger.info(`카테고리 컬럼 ${result.rows.length}개 조회 완료`, {
tableName,
@ -77,84 +80,164 @@ class TableCategoryValueService {
}
/**
* ( )
* ( )
*
* :
* - menuObjid가
* - menuObjid가 ( )
*/
async getCategoryValues(
tableName: string,
columnName: string,
companyCode: string,
includeInactive: boolean = false
includeInactive: boolean = false,
menuObjid?: number
): Promise<TableCategoryValue[]> {
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 });
}
@ -173,6 +256,8 @@ class TableCategoryValueService {
tableName,
columnName,
companyCode,
menuObjid,
scopeType: menuObjid ? "menu" : "table",
});
return values;
@ -183,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<TableCategoryValue> {
const pool = getPool();
try {
// 중복 코드 체크 (멀티테넌시 적용)
logger.info("카테고리 값 추가 (메뉴 스코프)", {
tableName: value.tableName,
columnName: value.columnName,
valueCode: value.valueCode,
menuObjid,
companyCode,
});
// 중복 코드 체크 (멀티테넌시 + 메뉴 스코프)
let duplicateQuery: string;
let duplicateParams: any[];
@ -205,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 = `
@ -215,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);
@ -230,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",
@ -247,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"
`;
@ -265,6 +367,7 @@ class TableCategoryValueService {
value.isActive !== false,
value.isDefault || false,
companyCode,
menuObjid, // ← 메뉴 OBJID 저장
userId,
]);
@ -272,6 +375,7 @@ class TableCategoryValueService {
valueId: result.rows[0].valueId,
tableName: value.tableName,
columnName: value.columnName,
menuObjid,
});
return result.rows[0];

View File

@ -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();
@ -399,11 +403,13 @@ export default function ScreenViewPage() {
isSelected={false}
isDesignMode={false}
onClick={() => {}}
menuObjid={menuObjid}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData}
sortBy={tableSortBy}
sortOrder={tableSortOrder}
@ -463,11 +469,13 @@ export default function ScreenViewPage() {
isSelected={false}
isDesignMode={false}
onClick={() => {}}
menuObjid={menuObjid}
screenId={screenId}
tableName={screen?.tableName}
userId={user?.userId}
userName={userName}
companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData}
sortBy={tableSortBy}
sortOrder={tableSortOrder}

View File

@ -259,6 +259,12 @@ function AppLayoutInner({ children }: AppLayoutProps) {
if (menu.hasChildren) {
toggleMenu(menu.id);
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.label || menu.name || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.objid || menu.id;
@ -268,10 +274,14 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
// 관리자 모드 상태를 쿼리 파라미터로 전달
const screenPath = isAdminMode
? `/screens/${firstScreen.screenId}?mode=admin`
: `/screens/${firstScreen.screenId}`;
// 관리자 모드 상태와 menuObjid를 쿼리 파라미터로 전달
const params = new URLSearchParams();
if (isAdminMode) {
params.set("mode", "admin");
}
params.set("menuObjid", menuObjid.toString());
const screenPath = `/screens/${firstScreen.screenId}?${params.toString()}`;
router.push(screenPath);
if (isMobile) {

View File

@ -12,7 +12,7 @@ import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule";
import { NumberingRuleCard } from "./NumberingRuleCard";
import { NumberingRulePreview } from "./NumberingRulePreview";
import {
getNumberingRules,
getAvailableNumberingRules,
createNumberingRule,
updateNumberingRule,
deleteNumberingRule,
@ -26,6 +26,7 @@ interface NumberingRuleDesignerProps {
isPreview?: boolean;
className?: string;
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
}
export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
@ -36,6 +37,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
isPreview = false,
className = "",
currentTableName,
menuObjid,
}) => {
const [savedRules, setSavedRules] = useState<NumberingRuleConfig[]>([]);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
@ -53,7 +55,20 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
const loadRules = useCallback(async () => {
setLoading(true);
try {
const response = await getNumberingRules();
console.log("🔍 [NumberingRuleDesigner] 채번 규칙 목록 로드 시작:", {
menuObjid,
hasMenuObjid: !!menuObjid,
});
const response = await getAvailableNumberingRules(menuObjid);
console.log("📦 [NumberingRuleDesigner] 채번 규칙 API 응답:", {
menuObjid,
success: response.success,
rulesCount: response.data?.length || 0,
rules: response.data,
});
if (response.success && response.data) {
setSavedRules(response.data);
} else {
@ -64,7 +79,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
} finally {
setLoading(false);
}
}, []);
}, [menuObjid]);
useEffect(() => {
if (currentRule) {
@ -133,19 +148,23 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
try {
const existing = savedRules.find((r) => r.ruleId === currentRule.ruleId);
// 저장 전에 현재 화면의 테이블명 자동 설정
// 저장 전에 현재 화면의 테이블명과 menuObjid 자동 설정
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // 항상 table로 고정
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("💾 채번 규칙 저장:", {
currentTableName,
menuObjid,
"currentRule.tableName": currentRule.tableName,
"currentRule.menuObjid": currentRule.menuObjid,
"ruleToSave.tableName": ruleToSave.tableName,
"ruleToSave.menuObjid": ruleToSave.menuObjid,
"ruleToSave.scopeType": ruleToSave.scopeType,
ruleToSave
ruleToSave,
});
let response;
@ -213,8 +232,8 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
);
const handleNewRule = useCallback(() => {
console.log("📋 새 규칙 생성 - currentTableName:", currentTableName);
console.log("📋 새 규칙 생성:", { currentTableName, menuObjid });
const newRule: NumberingRuleConfig = {
ruleId: `rule-${Date.now()}`,
ruleName: "새 채번 규칙",
@ -222,17 +241,18 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
separator: "-",
resetPeriod: "none",
currentSequence: 1,
scopeType: "table", // 기본값을 table로 설정
scopeType: "table", // ⚠️ 임시: DB 제약 조건 때문에 table 유지
tableName: currentTableName || "", // 현재 화면의 테이블명 자동 설정
menuObjid: menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
console.log("📋 생성된 규칙 정보:", newRule);
setSelectedRuleId(newRule.ruleId);
setCurrentRule(newRule);
toast.success("새 규칙이 생성되었습니다");
}, [currentTableName]);
}, [currentTableName, menuObjid]);
return (
<div className={`flex h-full gap-4 ${className}`}>
@ -273,7 +293,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
savedRules.map((rule) => (
<Card
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"
}`}
onClick={() => handleSelectRule(rule)}
@ -356,7 +376,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
{currentTableName && (
<div className="space-y-2">
<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}
</div>
<p className="text-muted-foreground text-xs">

View File

@ -57,6 +57,7 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
// 새로운 검증 관련 옵션들
enableEnhancedValidation?: boolean;
tableColumns?: ColumnInfo[];
@ -76,6 +77,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
onFormDataChange,
hideLabel = false,
screenInfo,
menuObjid, // 🆕 메뉴 OBJID
enableEnhancedValidation = false,
tableColumns = [],
showValidationPanel = false,
@ -1090,15 +1092,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", {
console.log(`🔍 [InteractiveScreenViewer] Code 위젯 렌더링:`, {
componentId: widget.id,
widgetType: widget.widgetType,
columnName: widget.columnName,
fieldName,
currentValue,
formData,
config,
codeCategory: config?.codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
});
// code 타입은 공통코드 선택박스로 처리
@ -1117,6 +1116,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
required: required,
placeholder: config?.placeholder || "코드를 선택하세요...",
className: "w-full h-full",
menuObjid: menuObjid, // 🆕 메뉴 OBJID 전달
}}
config={{
...config,

View File

@ -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<RealtimePreviewProps> = ({
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
menuObjid, // 🆕 메뉴 OBJID
selectedRowsData,
onSelectedRowsChange,
flowSelectedData,
@ -344,6 +346,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
userId={userId}
userName={userName}
companyCode={companyCode}
menuObjid={menuObjid}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange}
flowSelectedData={flowSelectedData}

View File

@ -143,6 +143,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
const [isSaving, setIsSaving] = useState(false);
// 🆕 화면에 할당된 메뉴 OBJID
const [menuObjid, setMenuObjid] = useState<number | undefined>(undefined);
// 메뉴 할당 모달 상태
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
@ -880,6 +883,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const loadLayout = async () => {
try {
// 🆕 화면에 할당된 메뉴 조회
const menuInfo = await screenApi.getScreenMenu(selectedScreen.screenId);
if (menuInfo) {
setMenuObjid(menuInfo.menuObjid);
console.log("🔗 화면에 할당된 메뉴:", menuInfo);
} else {
console.warn("⚠️ 화면에 할당된 메뉴가 없습니다");
}
const response = await screenApi.getLayout(selectedScreen.screenId);
if (response) {
// 🔄 마이그레이션 필요 여부 확인
@ -4205,6 +4217,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
currentResolution={screenResolution}
onResolutionChange={handleResolutionChange}
allComponents={layout.components} // 🆕 플로우 위젯 감지용
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</TabsContent>
</Tabs>
@ -4497,6 +4510,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onDragStart={(e) => startComponentDrag(component, e)}
onDragEnd={endDrag}
selectedScreen={selectedScreen}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
// onZoneComponentDrop 제거
onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)

View File

@ -84,6 +84,8 @@ interface UnifiedPropertiesPanelProps {
onResolutionChange?: (resolution: { name: string; width: number; height: number }) => void;
// 🆕 플로우 위젯 감지용
allComponents?: ComponentData[];
// 🆕 메뉴 OBJID (코드/카테고리 스코프용)
menuObjid?: number;
}
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
@ -98,6 +100,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
currentTableName,
dragState,
onStyleChange,
menuObjid,
currentResolution,
onResolutionChange,
allComponents = [], // 🆕 기본값 빈 배열
@ -685,6 +688,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange:", newConfig);
// 개별 속성별로 업데이트하여 다른 속성과의 충돌 방지
@ -848,6 +852,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
screenTableName={widget.tableName || currentTable?.tableName || currentTableName}
tableColumns={currentTable?.columns || []}
tables={tables}
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
onChange={(newConfig) => {
console.log("🔄 DynamicComponentConfigPanel onChange (widget):", newConfig);
// 전체 componentConfig를 업데이트

View File

@ -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<TextTypeConfigPanelProps> = ({
@ -44,6 +44,10 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
// 채번 규칙 목록 상태
const [numberingRules, setNumberingRules] = useState<NumberingRuleConfig[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(menuObjid);
// 로컬 상태로 실시간 입력 관리
const [localValues, setLocalValues] = useState({
@ -60,31 +64,61 @@ export const TextTypeConfigPanel: React.FC<TextTypeConfigPanelProps> = ({
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<TextTypeConfigPanelProps> = ({
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<TextTypeConfigPanelProps> = ({
} 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<TextTypeConfigPanelProps> = ({
</Select>
</div>
{(() => {
console.log("🔍 메뉴 선택 UI 렌더링 체크:", {
autoValueType: localValues.autoValueType,
isNumberingRule: localValues.autoValueType === "numbering_rule",
parentMenusCount: parentMenus.length,
selectedMenuObjid,
});
return null;
})()}
{localValues.autoValueType === "numbering_rule" && (
<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}
>
<SelectTrigger className="mt-1 h-6 w-full px-2 py-0 text-xs" style={{ fontSize: "12px" }}>
<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.ruleId})
<>
{/* 부모 메뉴 선택 */}
<div>
<Label htmlFor="parentMenu" className="text-sm font-medium">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => setSelectedMenuObjid(parseInt(value))}
>
<SelectTrigger className="mt-1 h-8 w-full px-2 py-0 text-xs">
<SelectValue placeholder="채번 규칙을 사용할 메뉴 선택" />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px]">
</p>
</div>
) : (
parentMenus.map((menu) => {
const objid = menu.objid || menu.OBJID;
const menuName = menu.menu_name_kor || menu.MENU_NAME_KOR;
return (
<SelectItem key={objid} value={objid.toString()}>
{menuName}
</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" && (

View File

@ -6,19 +6,52 @@ import { CategoryValueManager } from "@/components/table-category/CategoryValueM
import { GripVertical } from "lucide-react";
interface CategoryWidgetProps {
widgetId: string;
tableName: string; // 현재 화면의 테이블
widgetId?: string;
tableName?: string; // 현재 화면의 테이블 (옵션 - 형제 메뉴 전체 표시)
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) - 필수
component?: any; // DynamicComponentRenderer에서 전달되는 컴포넌트 정보
[key: string]: any; // 추가 props 허용
}
/**
* ( )
* - 좌측: 현재
* - 우측: 선택된 ( )
* - 좌측: 형제 ( )
* - 우측: 선택된 ( )
*/
export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
export function CategoryWidget({ widgetId, tableName, menuObjid, component, ...props }: CategoryWidgetProps) {
// menuObjid가 없으면 경고 로그
React.useEffect(() => {
console.log("🔍 CategoryWidget 받은 props:", {
widgetId,
tableName,
menuObjid,
hasComponent: !!component,
propsKeys: Object.keys(props),
propsMenuObjid: props.menuObjid,
allProps: { widgetId, tableName, menuObjid, ...props },
});
if (!menuObjid && !props.menuObjid) {
console.warn("⚠️ CategoryWidget: menuObjid가 전달되지 않았습니다", {
component,
props,
allAvailableProps: { widgetId, tableName, menuObjid, ...props }
});
} else {
console.log("✅ CategoryWidget 렌더링", {
widgetId,
tableName,
menuObjid: menuObjid || props.menuObjid
});
}
}, [menuObjid, widgetId, tableName, component, props]);
// menuObjid 우선순위: 직접 전달된 값 > props에서 추출한 값
const effectiveMenuObjid = menuObjid || props.menuObjid;
const [selectedColumn, setSelectedColumn] = useState<{
columnName: string;
columnLabel: string;
tableName: string;
} | null>(null);
const [leftWidth, setLeftWidth] = useState(15); // 초기값 15%
@ -66,9 +99,10 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
<CategoryColumnList
tableName={tableName}
selectedColumn={selectedColumn?.columnName || null}
onColumnSelect={(columnName, columnLabel) =>
setSelectedColumn({ columnName, columnLabel })
onColumnSelect={(columnName, columnLabel, tableName) =>
setSelectedColumn({ columnName, columnLabel, tableName })
}
menuObjid={effectiveMenuObjid}
/>
</div>
@ -84,9 +118,10 @@ export function CategoryWidget({ widgetId, tableName }: CategoryWidgetProps) {
<div style={{ width: `${100 - leftWidth - 1}%` }} className="pl-3">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
tableName={selectedColumn.tableName}
columnName={selectedColumn.columnName}
columnLabel={selectedColumn.columnLabel}
menuObjid={effectiveMenuObjid}
/>
) : (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm">

View File

@ -6,6 +6,8 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue";
import { FolderTree, Loader2 } from "lucide-react";
interface CategoryColumn {
tableName: string;
tableLabel?: string; // 테이블 라벨 추가
columnName: string;
columnLabel: string;
inputType: string;
@ -13,94 +15,85 @@ interface CategoryColumn {
}
interface CategoryColumnListProps {
tableName: string;
tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시)
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void;
onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void;
menuObjid?: number; // 현재 메뉴 OBJID (필수)
}
/**
* ( )
* - input_type='category' ( )
* - ( )
*/
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }: CategoryColumnListProps) {
export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryColumns();
}, [tableName]);
if (menuObjid) {
loadCategoryColumnsByMenu();
} else {
console.warn("⚠️ menuObjid가 없어서 카테고리 컬럼을 로드할 수 없습니다");
setColumns([]);
}
}, [menuObjid]);
const loadCategoryColumns = async () => {
const loadCategoryColumnsByMenu = async () => {
setIsLoading(true);
try {
// table_type_columns에서 input_type = 'category'인 컬럼 조회
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
console.log("🔍 형제 메뉴의 카테고리 컬럼 조회 시작", { menuObjid });
// 새 API: 형제 메뉴들의 카테고리 컬럼 조회
const response = await apiClient.get(`/table-management/menu/${menuObjid}/category-columns`);
console.log("🔍 테이블 컬럼 API 응답:", {
tableName,
console.log("✅ 메뉴별 카테고리 컬럼 API 응답:", {
menuObjid,
response: response.data,
type: typeof response.data,
isArray: Array.isArray(response.data),
});
// API 응답 구조 파싱 (여러 가능성 대응)
let allColumns: any[] = [];
let categoryColumns: any[] = [];
if (Array.isArray(response.data)) {
// response.data가 직접 배열인 경우
allColumns = response.data;
} else if (response.data.data && response.data.data.columns && Array.isArray(response.data.data.columns)) {
// response.data.data.columns가 배열인 경우 (table-management API)
allColumns = response.data.data.columns;
} else if (response.data.data && Array.isArray(response.data.data)) {
// response.data.data가 배열인 경우
allColumns = response.data.data;
} else if (response.data.columns && Array.isArray(response.data.columns)) {
// response.data.columns가 배열인 경우
allColumns = response.data.columns;
if (response.data.success && response.data.data) {
categoryColumns = response.data.data;
} else if (Array.isArray(response.data)) {
categoryColumns = response.data;
} else {
console.warn("⚠️ 예상하지 못한 API 응답 구조:", response.data);
allColumns = [];
categoryColumns = [];
}
console.log("🔍 파싱된 컬럼 목록:", {
totalColumns: allColumns.length,
sample: allColumns.slice(0, 3),
});
// category 타입만 필터링
const categoryColumns = allColumns.filter(
(col: any) => col.inputType === "category" || col.input_type === "category",
);
console.log("✅ 카테고리 컬럼:", {
console.log("✅ 카테고리 컬럼 파싱 완료:", {
count: categoryColumns.length,
columns: categoryColumns.map((c: any) => ({
name: c.columnName || c.column_name,
type: c.inputType || c.input_type,
table: c.tableName,
column: c.columnName,
label: c.columnLabel,
})),
});
// 각 컬럼의 값 개수 가져오기
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || col.displayName || colName;
const colTable = col.tableName;
const colName = col.columnName;
const colLabel = col.columnLabel || colName;
// 각 컬럼의 값 개수 가져오기
let valueCount = 0;
try {
const valuesResult = await getCategoryValues(tableName, colName, false);
const valuesResult = await getCategoryValues(colTable, colName, false, menuObjid);
if (valuesResult.success && valuesResult.data) {
valueCount = valuesResult.data.length;
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${colName}):`, error);
console.error(`항목 개수 조회 실패 (${colTable}.${colName}):`, error);
}
return {
tableName: colTable,
tableLabel: col.tableLabel || colTable, // 테이블 라벨 추가
columnName: colName,
columnLabel: colLabel,
inputType: col.inputType || col.input_type,
inputType: col.inputType,
valueCount,
};
}),
@ -111,7 +104,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
onColumnSelect(firstCol.columnName, firstCol.columnLabel, firstCol.tableName);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
@ -152,27 +145,31 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect }
</div>
<div className="space-y-2">
{columns.map((column) => (
<div
key={column.columnName}
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
{columns.map((column) => {
const uniqueKey = `${column.tableName}.${column.columnName}`;
return (
<div
key={uniqueKey}
onClick={() => onColumnSelect(column.columnName, column.columnLabel || column.columnName, column.tableName)}
className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${
selectedColumn === column.columnName ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50"
}`}
>
<div className="flex items-center gap-2">
<FolderTree
className={`h-4 w-4 ${selectedColumn === column.columnName ? "text-primary" : "text-muted-foreground"}`}
/>
<div className="flex-1">
<h4 className="text-sm font-semibold">{column.columnLabel || column.columnName}</h4>
<p className="text-muted-foreground text-xs">{column.tableLabel || column.tableName}</p>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
<span className="text-muted-foreground text-xs font-medium">
{column.valueCount !== undefined ? `${column.valueCount}` : "..."}
</span>
</div>
</div>
))}
);
})}
</div>
</div>
);

View File

@ -29,6 +29,7 @@ interface CategoryValueManagerProps {
columnName: string;
columnLabel: string;
onValueCountChange?: (count: number) => void;
menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프)
}
export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
@ -36,6 +37,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
columnName,
columnLabel,
onValueCountChange,
menuObjid,
}) => {
const { toast } = useToast();
const [values, setValues] = useState<TableCategoryValue[]>([]);
@ -81,7 +83,7 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
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<CategoryValueManagerProps> = ({
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<CategoryValueManagerProps> = ({
title: "오류",
description: error.message || "카테고리 값 추가에 실패했습니다",
variant: "destructive",
});
});
}
};

View File

@ -33,7 +33,6 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
queryFn: async () => {
if (!tableName || !columnName) return null;
console.log(`🔍 [React Query] 테이블 코드 카테고리 조회: ${tableName}.${columnName}`);
const columns = await tableTypeApi.getColumns(tableName);
const targetColumn = columns.find((col) => col.columnName === columnName);
@ -41,7 +40,6 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
? targetColumn.codeCategory
: null;
console.log(`✅ [React Query] 테이블 코드 카테고리 결과: ${tableName}.${columnName} -> ${codeCategory}`);
return codeCategory;
},
enabled: !!(tableName && columnName),
@ -51,14 +49,32 @@ export function useTableCodeCategory(tableName?: string, columnName?: string) {
}
// 코드 옵션 조회 (select용)
export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
export function useCodeOptions(codeCategory?: string, enabled: boolean = true, menuObjid?: number) {
const query = useQuery({
queryKey: queryKeys.codes.options(codeCategory || ""),
queryKey: menuObjid
? [...queryKeys.codes.options(codeCategory || ""), 'menu', menuObjid]
: queryKeys.codes.options(codeCategory || ""),
queryFn: async () => {
if (!codeCategory || codeCategory === "none") return [];
console.log(`🔍 [React Query] 코드 옵션 조회: ${codeCategory}`);
const response = await commonCodeApi.codes.getList(codeCategory, { isActive: true });
console.log(`🔍 [useCodeOptions] 코드 옵션 조회 시작:`, {
codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
});
const response = await commonCodeApi.codes.getList(codeCategory, {
isActive: true,
menuObjid
});
console.log(`📦 [useCodeOptions] API 응답:`, {
codeCategory,
menuObjid,
success: response.success,
dataCount: response.data?.length || 0,
rawData: response.data,
});
if (response.success && response.data) {
const options = response.data.map((code: any) => {
@ -73,7 +89,13 @@ export function useCodeOptions(codeCategory?: string, enabled: boolean = true) {
};
});
console.log(`✅ [React Query] 코드 옵션 결과: ${codeCategory} (${options.length}개)`);
console.log(`✅ [useCodeOptions] 옵션 변환 완료:`, {
codeCategory,
menuObjid,
optionsCount: options.length,
options,
});
return options;
}

View File

@ -160,6 +160,12 @@ export const useMenu = (user: any, authLoading: boolean) => {
if (menu.children && menu.children.length > 0) {
toggleMenu(String(menu.OBJID));
} else {
// 메뉴 이름 저장 (엑셀 다운로드 파일명에 사용)
const menuName = menu.MENU_NAME_KOR || menu.menuNameKor || menu.TRANSLATED_NAME || "메뉴";
if (typeof window !== "undefined") {
localStorage.setItem("currentMenuName", menuName);
}
// 먼저 할당된 화면이 있는지 확인 (URL 유무와 관계없이)
try {
const menuObjid = menu.OBJID || menu.objid;
@ -170,7 +176,8 @@ export const useMenu = (user: any, authLoading: boolean) => {
if (assignedScreens.length > 0) {
// 할당된 화면이 있으면 첫 번째 화면으로 이동
const firstScreen = assignedScreens[0];
router.push(`/screens/${firstScreen.screenId}`);
// menuObjid를 쿼리 파라미터로 전달
router.push(`/screens/${firstScreen.screenId}?menuObjid=${menuObjid}`);
return;
}
}

View File

@ -66,13 +66,14 @@ export const commonCodeApi = {
/**
*
*/
async getList(categoryCode: string, params?: GetCodesQuery): Promise<ApiResponse<CodeInfo[]>> {
async getList(categoryCode: string, params?: GetCodesQuery & { menuObjid?: number }): Promise<ApiResponse<CodeInfo[]>> {
const searchParams = new URLSearchParams();
if (params?.search) searchParams.append("search", params.search);
if (params?.isActive !== undefined) searchParams.append("isActive", params.isActive.toString());
if (params?.page !== undefined) searchParams.append("page", params.page.toString());
if (params?.size !== undefined) searchParams.append("size", params.size.toString());
if (params?.menuObjid !== undefined) searchParams.append("menuObjid", params.menuObjid.toString());
const queryString = searchParams.toString();
const url = `/common-codes/categories/${categoryCode}/codes${queryString ? `?${queryString}` : ""}`;

View File

@ -46,6 +46,12 @@ export const screenApi = {
} as ScreenDefinition;
},
// 화면에 할당된 메뉴 조회
getScreenMenu: async (screenId: number): Promise<{ menuObjid: number; menuName?: string } | null> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/menu`);
return response.data?.data || null;
},
// 화면 생성
createScreen: async (screenData: CreateScreenRequest): Promise<ScreenDefinition> => {
const response = await apiClient.post("/screen-management/screens", screenData);

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(
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);

View File

@ -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<DynamicComponentRendererProps> =
onFormDataChange,
tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보
onRefresh,
onClose,
@ -319,6 +321,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onChange: handleChange, // 개선된 onChange 핸들러 전달
tableName,
menuId, // 🆕 메뉴 ID
menuObjid, // 🆕 메뉴 OBJID (메뉴 스코프)
selectedScreen, // 🆕 화면 정보
onRefresh,
onClose,

View File

@ -552,8 +552,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
margin: "0",
lineHeight: "1.25",
boxShadow: componentConfig.disabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// isInteractive 모드에서는 사용자 스타일 우선 적용 (width/height 제외)
...(isInteractive && component.style ? Object.fromEntries(
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
) : {}),
};

View File

@ -9,6 +9,7 @@ interface NumberingRuleWrapperProps {
onChange?: (config: NumberingRuleComponentConfig) => void;
isPreview?: boolean;
tableName?: string; // 현재 화면의 테이블명
menuObjid?: number; // 🆕 메뉴 OBJID
}
export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
@ -16,8 +17,14 @@ export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
onChange,
isPreview = false,
tableName,
menuObjid,
}) => {
console.log("📋 NumberingRuleWrapper: 테이블명 전달", { tableName, config });
console.log("📋 NumberingRuleWrapper: 테이블명 + menuObjid 전달", {
tableName,
menuObjid,
hasMenuObjid: !!menuObjid,
config
});
return (
<div className="h-full w-full">
@ -26,6 +33,7 @@ export const NumberingRuleWrapper: React.FC<NumberingRuleWrapperProps> = ({
isPreview={isPreview}
className="h-full"
currentTableName={tableName} // 테이블명 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
</div>
);

View File

@ -23,6 +23,7 @@ export interface SelectBasicComponentProps {
onDragStart?: () => void;
onDragEnd?: () => void;
value?: any; // 외부에서 전달받는 값
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
[key: string]: any;
}
@ -46,19 +47,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onDragStart,
onDragEnd,
value: externalValue, // 명시적으로 value prop 받기
menuObjid, // 🆕 메뉴 OBJID
...props
}) => {
// 🚨 최우선 디버깅: 컴포넌트가 실행되는지 확인
console.log("🚨🚨🚨 SelectBasicComponent 실행됨!!!", {
componentId: component?.id,
componentType: component?.type,
webType: component?.webType,
tableName: component?.tableName,
columnName: component?.columnName,
screenId,
timestamp: new Date().toISOString(),
});
const [isOpen, setIsOpen] = useState(false);
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
@ -77,30 +68,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// autocomplete의 경우 검색어 관리
const [searchQuery, setSearchQuery] = useState("");
console.log("🔍 SelectBasicComponent 초기화 (React Query):", {
componentId: component.id,
externalValue,
componentConfigValue: componentConfig?.value,
webTypeConfigValue: (props as any).webTypeConfig?.value,
configValue: config?.value,
finalSelectedValue: externalValue || config?.value || "",
tableName: component.tableName,
columnName: component.columnName,
staticCodeCategory: config?.codeCategory,
// React Query 디버깅 정보
timestamp: new Date().toISOString(),
mountCount: ++(window as any).selectMountCount || ((window as any).selectMountCount = 1),
});
// 언마운트 시 로깅
useEffect(() => {
const componentId = component.id;
console.log(`🔍 [${componentId}] SelectBasicComponent 마운트됨`);
return () => {
console.log(`🔍 [${componentId}] SelectBasicComponent 언마운트됨`);
};
}, [component.id]);
const selectRef = useRef<HTMLDivElement>(null);
@ -115,11 +82,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리 (메모이제이션)
const codeCategory = useMemo(() => {
const category = dynamicCodeCategory || staticCodeCategory;
console.log(`🔑 [${component.id}] 코드 카테고리 결정:`, {
dynamicCodeCategory,
staticCodeCategory,
finalCategory: category,
});
return category;
}, [dynamicCodeCategory, staticCodeCategory, component.id]);
@ -132,34 +94,27 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
options: codeOptions,
isLoading: isLoadingCodes,
isFetching,
} = useCodeOptions(codeCategory, isCodeCategoryValid);
} = useCodeOptions(codeCategory, isCodeCategoryValid, menuObjid);
// React Query 상태 디버깅
// 디버깅: menuObjid가 제대로 전달되는지 확인
useEffect(() => {
console.log(`🎯 [${component.id}] React Query 상태:`, {
codeCategory,
isCodeCategoryValid,
codeOptionsLength: codeOptions.length,
isLoadingCodes,
isFetching,
cacheStatus: isFetching ? "FETCHING" : "FROM_CACHE",
});
}, [component.id, codeCategory, isCodeCategoryValid, codeOptions.length, isLoadingCodes, isFetching]);
if (codeCategory && codeCategory !== "none") {
console.log(`🎯 [SelectBasicComponent ${component.id}] 코드 옵션 로드:`, {
codeCategory,
menuObjid,
hasMenuObjid: !!menuObjid,
isCodeCategoryValid,
codeOptionsCount: codeOptions.length,
isLoading: isLoadingCodes,
});
}
}, [component.id, codeCategory, menuObjid, codeOptions.length, isLoadingCodes, isCodeCategoryValid]);
// 외부 value prop 변경 시 selectedValue 업데이트
useEffect(() => {
const newValue = externalValue || config?.value || "";
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
if (newValue !== selectedValue) {
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
console.log("🔍 업데이트 조건 분석:", {
externalValue,
componentConfigValue: componentConfig?.value,
configValue: config?.value,
newValue,
selectedValue,
shouldUpdate: newValue !== selectedValue,
});
setSelectedValue(newValue);
}
}, [externalValue, config?.value]);
@ -188,23 +143,12 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const labelMatch = options.find((option) => option.label === selectedValue);
if (labelMatch) {
newLabel = labelMatch.label;
console.log(`🔍 [${component.id}] 코드명으로 매치 발견: "${selectedValue}" → "${newLabel}"`);
} else {
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
console.log(`🔍 [${component.id}] 코드값 원본 유지: "${selectedValue}"`);
}
}
console.log(`🏷️ [${component.id}] 라벨 업데이트:`, {
selectedValue,
selectedOption: selectedOption ? { value: selectedOption.value, label: selectedOption.label } : null,
newLabel,
optionsCount: options.length,
allOptionsValues: options.map((o) => o.value),
allOptionsLabels: options.map((o) => o.label),
});
if (newLabel !== selectedLabel) {
setSelectedLabel(newLabel);
}
@ -214,15 +158,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const handleToggle = () => {
if (isDesignMode) return;
console.log(`🖱️ [${component.id}] 드롭다운 토글 (React Query): ${isOpen}${!isOpen}`);
console.log(`📊 [${component.id}] 현재 상태:`, {
codeCategory,
isLoadingCodes,
codeOptionsLength: codeOptions.length,
tableName: component.tableName,
columnName: component.columnName,
});
// React Query가 자동으로 캐시 관리하므로 수동 새로고침 불필요
setIsOpen(!isOpen);
};
@ -240,17 +175,8 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
if (isInteractive && onFormDataChange && component.columnName) {
console.log(`📤 SelectBasicComponent -> onFormDataChange 호출: ${component.columnName} = "${value}"`);
onFormDataChange(component.columnName, value);
} else {
console.log("❌ SelectBasicComponent onFormDataChange 조건 미충족:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
hasColumnName: !!component.columnName,
});
}
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
};
// 외부 클릭 시 드롭다운 닫기
@ -278,12 +204,6 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기
const getAllOptions = () => {
const configOptions = config.options || [];
console.log(`🔧 [${component.id}] 옵션 병합:`, {
codeOptionsLength: codeOptions.length,
codeOptions: codeOptions.map((o: Option) => ({ value: o.value, label: o.label })),
configOptionsLength: configOptions.length,
configOptions: configOptions.map((o: Option) => ({ value: o.value, label: o.label })),
});
return [...codeOptions, ...configOptions];
};

View File

@ -48,6 +48,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [isLoadingRight, setIsLoadingRight] = useState(false);
const [rightTableColumns, setRightTableColumns] = useState<any[]>([]); // 우측 테이블 컬럼 정보
const [expandedItems, setExpandedItems] = useState<Set<any>>(new Set()); // 펼쳐진 항목들
const [leftColumnLabels, setLeftColumnLabels] = useState<Record<string, string>>({}); // 좌측 컬럼 라벨
const [rightColumnLabels, setRightColumnLabels] = useState<Record<string, string>>({}); // 우측 컬럼 라벨
const { toast } = useToast();
// 추가 모달 상태
@ -270,6 +272,32 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
[rightTableColumns],
);
// 좌측 테이블 컬럼 라벨 로드
useEffect(() => {
const loadLeftColumnLabels = async () => {
const leftTableName = componentConfig.leftPanel?.tableName;
if (!leftTableName || isDesignMode) return;
try {
const columnsResponse = await tableTypeApi.getColumns(leftTableName);
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setLeftColumnLabels(labels);
console.log("✅ 좌측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("좌측 테이블 컬럼 라벨 로드 실패:", error);
}
};
loadLeftColumnLabels();
}, [componentConfig.leftPanel?.tableName, isDesignMode]);
// 우측 테이블 컬럼 정보 로드
useEffect(() => {
const loadRightTableColumns = async () => {
@ -279,6 +307,18 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
try {
const columnsResponse = await tableTypeApi.getColumns(rightTableName);
setRightTableColumns(columnsResponse || []);
// 우측 컬럼 라벨도 함께 로드
const labels: Record<string, string> = {};
columnsResponse.forEach((col: any) => {
const columnName = col.columnName || col.column_name;
const label = col.columnLabel || col.column_label || col.displayName || columnName;
if (columnName) {
labels[columnName] = label;
}
});
setRightColumnLabels(labels);
console.log("✅ 우측 컬럼 라벨 로드:", labels);
} catch (error) {
console.error("우측 테이블 컬럼 정보 로드 실패:", error);
}
@ -784,46 +824,157 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)}
</CardHeader>
<CardContent className="flex-1 overflow-auto p-4">
{/* 좌측 데이터 목록 */}
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
<div
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 1</div>
<div className="text-muted-foreground text-xs"> </div>
{/* 좌측 데이터 목록/테이블 */}
{componentConfig.leftPanel?.displayMode === "table" ? (
// 테이블 모드
<div className="w-full">
{isDesignMode ? (
// 디자인 모드: 샘플 테이블
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 1</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 2</th>
<th className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase"> 3</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 1-3</td>
</tr>
<tr className="hover:bg-gray-50 cursor-pointer">
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-1</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-2</td>
<td className="whitespace-nowrap px-3 py-2 text-sm"> 2-3</td>
</tr>
</tbody>
</table>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
) : isLoadingLeft ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 3</div>
<div className="text-muted-foreground text-xs"> </div>
) : (
(() => {
const filteredData = leftSearchQuery
? leftData.filter((item) => {
const searchLower = leftSearchQuery.toLowerCase();
return Object.entries(item).some(([key, value]) => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchLower);
});
})
: leftData;
const displayColumns = componentConfig.leftPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: leftColumnLabels[col.name] || col.label || col.name
}))
: Object.keys(filteredData[0] || {}).filter(key => key !== 'children' && key !== 'level').slice(0, 5).map(key => ({
name: key,
label: leftColumnLabels[key] || key,
width: 150,
align: "left" as const
}));
return (
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || 'id';
const itemId = item[sourceColumn] || item.id || item.ID || idx;
const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item);
return (
<tr
key={itemId}
onClick={() => handleLeftItemSelect(item)}
className={`hover:bg-accent cursor-pointer transition-colors ${
isSelected ? "bg-primary/10" : ""
}`}
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
})()
)}
</div>
) : (
// 목록 모드 (기존)
<div className="space-y-1">
{isDesignMode ? (
// 디자인 모드: 샘플 데이터
<>
<div
onClick={() => handleLeftItemSelect({ id: 1, name: "항목 1" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 1 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 1</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 2, name: "항목 2" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 2 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 2</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
<div
onClick={() => handleLeftItemSelect({ id: 3, name: "항목 3" })}
className={`hover:bg-accent cursor-pointer rounded-md p-3 transition-colors ${
selectedLeftItem?.id === 3 ? "bg-primary/10 text-primary" : ""
}`}
>
<div className="font-medium"> 3</div>
<div className="text-muted-foreground text-xs"> </div>
</div>
</>
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
</>
) : isLoadingLeft ? (
// 로딩 중
<div className="flex items-center justify-center py-8">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : (
) : (
(() => {
// 검색 필터링 (클라이언트 사이드)
const filteredLeftData = leftSearchQuery
@ -1001,7 +1152,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
);
})()
)}
</div>
</div>
)}
</CardContent>
</Card>
</div>
@ -1081,6 +1233,107 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
})
: rightData;
// 테이블 모드 체크
const isTableMode = componentConfig.rightPanel?.displayMode === "table";
if (isTableMode) {
// 테이블 모드 렌더링
const displayColumns = componentConfig.rightPanel?.columns || [];
const columnsToShow = displayColumns.length > 0
? displayColumns.map(col => ({
...col,
label: rightColumnLabels[col.name] || col.label || col.name
}))
: Object.keys(filteredData[0] || {}).filter(key => !key.toLowerCase().includes("password")).slice(0, 5).map(key => ({
name: key,
label: rightColumnLabels[key] || key,
width: 150,
align: "left" as const
}));
return (
<div className="w-full">
<div className="mb-2 text-xs text-muted-foreground">
{filteredData.length}
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="ml-1 text-primary">( {rightData.length} )</span>
)}
</div>
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 bg-gray-50 z-10">
<tr>
{columnsToShow.map((col, idx) => (
<th
key={idx}
className="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
style={{ width: col.width ? `${col.width}px` : 'auto', textAlign: col.align || "left" }}
>
{col.label}
</th>
))}
{!isDesignMode && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase"></th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx;
return (
<tr
key={itemId}
className="hover:bg-accent transition-colors"
>
{columnsToShow.map((col, colIdx) => (
<td
key={colIdx}
className="whitespace-nowrap px-3 py-2 text-sm text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{item[col.name] !== null && item[col.name] !== undefined
? String(item[col.name])
: "-"}
</td>
))}
{!isDesignMode && (
<td className="whitespace-nowrap px-3 py-2 text-right text-sm">
<div className="flex justify-end gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="rounded p-1 hover:bg-gray-200 transition-colors"
title="수정"
>
<Pencil className="h-4 w-4 text-gray-600" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 hover:bg-red-100 transition-colors"
title="삭제"
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
</div>
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
// 목록 모드 (기존)
return filteredData.length > 0 ? (
<div className="space-y-2">
<div className="mb-2 text-xs text-muted-foreground">

View File

@ -353,6 +353,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
</div>
<div className="space-y-2">
<Label> </Label>
<Select
value={config.leftPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateLeftPanel({ displayMode: value })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col">
<span className="font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col">
<span className="font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<Label> </Label>
<Switch
@ -670,6 +696,185 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
)}
{/* 좌측 패널 표시 컬럼 설정 */}
<div className="space-y-3 rounded-lg border border-green-200 bg-green-50 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button
size="sm"
variant="outline"
onClick={() => {
const currentColumns = config.leftPanel?.columns || [];
const newColumns = [
...currentColumns,
{ name: "", label: "", width: 100 },
];
updateLeftPanel({ columns: newColumns });
}}
className="h-7 text-xs"
disabled={!config.leftPanel?.tableName && !screenTableName}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-xs text-gray-600">
. .
</p>
{/* 선택된 컬럼 목록 */}
<div className="space-y-2">
{(config.leftPanel?.columns || []).length === 0 ? (
<div className="rounded-md border border-dashed border-gray-300 bg-white p-3 text-center">
<p className="text-xs text-gray-500"> </p>
<p className="mt-1 text-[10px] text-gray-400">
</p>
</div>
) : (
(config.leftPanel?.columns || []).map((col, index) => {
const isTableMode = config.leftPanel?.displayMode === "table";
return (
<div
key={index}
className="space-y-2 rounded-md border bg-white p-2"
>
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{leftTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateLeftPanel({ columns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.leftPanel?.columns || []).filter(
(_, i) => i !== index
);
updateLeftPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 테이블 모드 전용 옵션 */}
{isTableMode && (
<div className="grid grid-cols-3 gap-2 pt-1">
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"> (px)</Label>
<Input
type="number"
min="50"
value={col.width || 100}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
width: parseInt(e.target.value) || 100,
};
updateLeftPanel({ columns: newColumns });
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"></Label>
<Select
value={col.align || "left"}
onValueChange={(value: "left" | "center" | "right") => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
align: value,
};
updateLeftPanel({ columns: newColumns });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={col.sortable ?? false}
onChange={(e) => {
const newColumns = [...(config.leftPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
sortable: e.target.checked,
};
updateLeftPanel({ columns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
</div>
)}
</div>
);
})
)}
</div>
</div>
{/* 좌측 패널 추가 모달 컬럼 설정 */}
{config.leftPanel?.showAdd && (
<div className="space-y-3 rounded-lg border border-purple-200 bg-purple-50 p-3">
@ -895,6 +1100,32 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div>
)}
<div className="space-y-2">
<Label> </Label>
<Select
value={config.rightPanel?.displayMode || "list"}
onValueChange={(value: "list" | "table") => updateRightPanel({ displayMode: value })}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="표시 모드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">
<div className="flex flex-col">
<span className="font-medium"> (LIST)</span>
<span className="text-xs text-gray-500"> ()</span>
</div>
</SelectItem>
<SelectItem value="table">
<div className="flex flex-col">
<span className="font-medium"> (TABLE)</span>
<span className="text-xs text-gray-500"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 컬럼 매핑 - 조인 모드에서만 표시 */}
{relationshipType !== "detail" && (
<div className="space-y-3 rounded-lg border border-gray-200 bg-gray-50 p-3">
@ -1057,75 +1288,145 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</p>
</div>
) : (
(config.rightPanel?.columns || []).map((col, index) => (
(config.rightPanel?.columns || []).map((col, index) => {
const isTableMode = config.rightPanel?.displayMode === "table";
return (
<div
key={index}
className="flex items-center gap-2 rounded-md border bg-white p-2"
className="space-y-2 rounded-md border bg-white p-2"
>
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateRightPanel({ columns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<div className="flex items-center gap-2">
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{col.name || "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{rightTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={(value) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
name: value,
label: column.columnLabel || value,
};
updateRightPanel({ columns: newColumns });
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
col.name === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
{column.columnLabel || column.columnName}
<span className="ml-2 text-[10px] text-gray-500">
({column.columnName})
</span>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.rightPanel?.columns || []).filter(
(_, i) => i !== index
);
updateRightPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newColumns = (config.rightPanel?.columns || []).filter(
(_, i) => i !== index
);
updateRightPanel({ columns: newColumns });
}}
className="h-8 w-8 p-0"
>
<X className="h-3 w-3" />
</Button>
{/* 테이블 모드 전용 옵션 */}
{isTableMode && (
<div className="grid grid-cols-3 gap-2 pt-1">
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"> (px)</Label>
<Input
type="number"
min="50"
value={col.width || 100}
onChange={(e) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
width: parseInt(e.target.value) || 100,
};
updateRightPanel({ columns: newColumns });
}}
className="h-7 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px] text-gray-600"></Label>
<Select
value={col.align || "left"}
onValueChange={(value: "left" | "center" | "right") => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
align: value,
};
updateRightPanel({ columns: newColumns });
}}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="left"></SelectItem>
<SelectItem value="center"></SelectItem>
<SelectItem value="right"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<label className="flex h-7 items-center gap-1 text-[10px] cursor-pointer">
<input
type="checkbox"
checked={col.sortable ?? false}
onChange={(e) => {
const newColumns = [...(config.rightPanel?.columns || [])];
newColumns[index] = {
...newColumns[index],
sortable: e.target.checked,
};
updateRightPanel({ columns: newColumns });
}}
className="h-3 w-3"
/>
</label>
</div>
</div>
)}
</div>
))
);
})
)}
</div>
</div>

View File

@ -8,6 +8,7 @@ export interface SplitPanelLayoutConfig {
title: string;
tableName?: string; // 데이터베이스 테이블명
dataSource?: string; // API 엔드포인트
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
showSearch?: boolean;
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
@ -16,6 +17,8 @@ export interface SplitPanelLayoutConfig {
name: string;
label: string;
width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -38,6 +41,17 @@ export interface SplitPanelLayoutConfig {
// 현재 항목의 어떤 컬럼 값을 parentColumn에 넣을지 (예: dept_code)
sourceColumn: string;
};
// 테이블 모드 설정
tableConfig?: {
showCheckbox?: boolean; // 체크박스 표시 여부
showRowNumber?: boolean; // 행 번호 표시 여부
rowHeight?: number; // 행 높이
headerHeight?: number; // 헤더 높이
striped?: boolean; // 줄무늬 배경
bordered?: boolean; // 테두리 표시
hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정
};
};
// 우측 패널 설정
@ -45,6 +59,7 @@ export interface SplitPanelLayoutConfig {
title: string;
tableName?: string;
dataSource?: string;
displayMode?: "list" | "table"; // 표시 모드: 목록 또는 테이블
showSearch?: boolean;
showAdd?: boolean;
showEdit?: boolean; // 수정 버튼
@ -53,6 +68,8 @@ export interface SplitPanelLayoutConfig {
name: string;
label: string;
width?: number;
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
}>;
// 추가 모달에서 입력받을 컬럼 설정
addModalColumns?: Array<{
@ -76,6 +93,18 @@ export interface SplitPanelLayoutConfig {
leftPanelColumn?: string; // 좌측 패널의 어떤 컬럼값을 가져올지
targetColumn?: string; // targetTable의 어떤 컬럼에 넣을지
};
// 테이블 모드 설정
tableConfig?: {
showCheckbox?: boolean; // 체크박스 표시 여부
showRowNumber?: boolean; // 행 번호 표시 여부
rowHeight?: number; // 행 높이
headerHeight?: number; // 헤더 높이
striped?: boolean; // 줄무늬 배경
bordered?: boolean; // 테두리 표시
hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정
};
};
// 레이아웃 설정

View File

@ -1992,7 +1992,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
>
{/* 스크롤 영역 */}
<div
className="bg-background h-[400px] w-full max-w-full overflow-x-auto overflow-y-scroll sm:h-[500px]"
className="bg-background flex-1 w-full max-w-full overflow-x-auto overflow-y-auto"
style={{ position: "relative" }}
>
{/* 테이블 */}

View File

@ -15,49 +15,86 @@ export interface TextInputConfigPanelProps {
config: TextInputConfig;
onChange: (config: Partial<TextInputConfig>) => void;
screenTableName?: string; // 🆕 현재 화면의 테이블명
menuObjid?: number; // 🆕 메뉴 OBJID (사용자 선택)
}
/**
* TextInput
* 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 [loadingRules, setLoadingRules] = useState(false);
// 부모 메뉴 목록 상태 (채번규칙 사용을 위한 선택)
const [parentMenus, setParentMenus] = useState<any[]>([]);
// useState 초기값에서 저장된 값 복원 (우선순위: 저장된 값 > menuObjid prop)
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | undefined>(() => {
return config.autoGeneration?.selectedMenuObjid || 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);
}
} catch (error) {
console.error("부모 메뉴 로드 실패:", error);
} finally {
setLoadingMenus(false);
}
};
loadMenus();
}, []);
// 채번 규칙 목록 로드 (선택된 메뉴 기준)
useEffect(() => {
const loadRules = async () => {
// autoGeneration.type이 numbering_rule이 아니면 로드하지 않음
if (config.autoGeneration?.type !== "numbering_rule") {
return;
}
// 메뉴가 선택되지 않았으면 로드하지 않음
if (!selectedMenuObjid) {
setNumberingRules([]);
return;
}
setLoadingRules(true);
try {
let response;
// 🆕 테이블명이 있으면 테이블 기반 필터링, 없으면 전체 조회
if (screenTableName) {
console.log("🔍 TextInputConfigPanel: 테이블 기반 채번 규칙 로드", { screenTableName });
response = await getAvailableNumberingRulesForScreen(screenTableName);
} else {
console.log("🔍 TextInputConfigPanel: 전체 채번 규칙 로드 (테이블명 없음)");
response = await getAvailableNumberingRules();
}
const response = await getAvailableNumberingRules(selectedMenuObjid);
if (response.success && response.data) {
setNumberingRules(response.data);
console.log("✅ 채번 규칙 로드 완료:", response.data.length, "개");
}
} catch (error) {
console.error("채번 규칙 목록 로드 실패:", error);
setNumberingRules([]);
} finally {
setLoadingRules(false);
}
};
// autoGeneration.type이 numbering_rule일 때만 로드
if (config.autoGeneration?.type === "numbering_rule") {
loadRules();
}
}, [config.autoGeneration?.type, screenTableName]);
loadRules();
}, [selectedMenuObjid, config.autoGeneration?.type]);
const handleChange = (key: keyof TextInputConfig, value: any) => {
onChange({ [key]: value });
@ -157,50 +194,105 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ conf
{/* 채번 규칙 선택 */}
{config.autoGeneration?.type === "numbering_rule" && (
<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>
)}
<>
{/* 부모 메뉴 선택 */}
<div className="space-y-2">
<Label htmlFor="targetMenu">
<span className="text-destructive">*</span>
</Label>
<Select
value={selectedMenuObjid?.toString() || ""}
onValueChange={(value) => {
const menuObjid = parseInt(value);
setSelectedMenuObjid(menuObjid);
// 컴포넌트 설정에 저장하여 언마운트 시에도 유지
handleChange("autoGeneration", {
...config.autoGeneration,
selectedMenuObjid: menuObjid,
});
}}
disabled={loadingMenus}
>
<SelectTrigger>
<SelectValue placeholder={loadingMenus ? "메뉴 로딩 중..." : "채번규칙을 사용할 메뉴 선택"} />
</SelectTrigger>
<SelectContent>
{parentMenus.length === 0 ? (
<SelectItem value="no-menus" disabled>
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
</p>
</div>
) : (
parentMenus.map((menu) => (
<SelectItem key={menu.objid} value={menu.objid.toString()}>
{menu.menu_name_kor}
{menu.menu_name_eng && (
<span className="text-muted-foreground ml-2 text-xs">
({menu.menu_name_eng})
</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>
)}

View File

@ -1970,7 +1970,7 @@ export class ButtonActionExecutor {
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
search: filterConditions, // ✅ 필터 조건
enableEntityJoin: true, // ✅ Entity 조인
autoFilter: true, // ✅ company_code 자동 필터링 (멀티테넌시)
// autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨
};
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
@ -2027,8 +2027,18 @@ export class ButtonActionExecutor {
return false;
}
// 파일명 생성
const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`;
// 파일명 생성 (메뉴 이름 우선 사용)
let defaultFileName = context.tableName || "데이터";
// localStorage에서 메뉴 이름 가져오기
if (typeof window !== "undefined") {
const menuName = localStorage.getItem("currentMenuName");
if (menuName) {
defaultFileName = menuName;
}
}
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;

View File

@ -107,6 +107,7 @@ export interface ComponentConfigPanelProps {
screenTableName?: string; // 화면에서 지정한 테이블명
tableColumns?: any[]; // 테이블 컬럼 정보
tables?: any[]; // 전체 테이블 목록
menuObjid?: number; // 🆕 메뉴 OBJID (코드/카테고리/채번규칙 스코프용)
}
export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> = ({
@ -116,6 +117,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
screenTableName,
tableColumns,
tables,
menuObjid,
}) => {
// 모든 useState를 최상단에 선언 (Hooks 규칙)
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
@ -259,6 +261,7 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
tables={tables} // 기본 테이블 목록 (현재 화면의 테이블만)
allTables={componentId === "repeater-field-group" ? allTablesList : tables} // RepeaterConfigPanel만 전체 테이블
onTableChange={handleTableChange} // 테이블 변경 핸들러 전달
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
);
};

View File

@ -0,0 +1,977 @@
# 카테고리 컴포넌트 메뉴 기반 전환 계획서
## 📋 현재 문제점
### 테이블 기반 스코프의 한계
**현재 상황**:
- 카테고리와 채번 컴포넌트가 **테이블 기준**으로 데이터를 불러옴
- `table_column_category_values` 테이블에서 `table_name + column_name`으로 카테고리 조회
**문제 발생**:
```
영업관리 (menu_id: 200)
├── 고객관리 (menu_id: 201) - 테이블: customer_info
├── 계약관리 (menu_id: 202) - 테이블: contract_info
├── 주문관리 (menu_id: 203) - 테이블: order_info
└── 영업관리 공통코드 (menu_id: 204) - 어떤 테이블 선택?
```
**문제**:
- 영업관리 전체에서 사용할 공통 코드/카테고리를 관리하고 싶은데
- 각 하위 메뉴가 서로 다른 테이블을 사용하므로
- 특정 테이블 하나를 선택하면 다른 메뉴에서 사용할 수 없음
### 예시: 영업관리 공통 코드 관리 불가
**원하는 동작**:
- "영업관리 > 공통코드 관리" 메뉴에서 카테고리 생성
- 이 카테고리는 영업관리의 **모든 하위 메뉴**에서 사용 가능
- 고객관리, 계약관리, 주문관리 화면 모두에서 같은 카테고리 공유
**현재 동작**:
- 테이블별로 카테고리가 격리됨
- `customer_info` 테이블의 카테고리는 `contract_info`에서 사용 불가
- 각 테이블마다 동일한 카테고리를 중복 생성해야 함 (비효율)
---
## ✅ 해결 방안: 메뉴 기반 스코프
### 핵심 개념
**메뉴 계층 구조를 카테고리 스코프로 사용**:
- 카테고리를 생성할 때 `menu_id`를 기록
- 같은 부모 메뉴를 가진 **형제 메뉴들**이 카테고리를 공유
- 테이블과 무관하게 메뉴 구조에 따라 스코프 결정
### 메뉴 스코프 규칙
```
영업관리 (parent_id: 0, menu_id: 200)
├── 고객관리 (parent_id: 200, menu_id: 201)
├── 계약관리 (parent_id: 200, menu_id: 202)
├── 주문관리 (parent_id: 200, menu_id: 203)
└── 공통코드 관리 (parent_id: 200, menu_id: 204) ← 여기서 카테고리 생성
```
**스코프 규칙**:
- 204번 메뉴에서 카테고리 생성 → `menu_id = 204`로 저장
- 형제 메뉴 (201, 202, 203, 204)에서 **모두 사용 가능**
- 다른 부모의 메뉴 (예: 구매관리)에서는 사용 불가
---
## 📐 데이터베이스 설계
### 기존 테이블 수정
```sql
-- table_column_category_values 테이블에 menu_id 추가
ALTER TABLE table_column_category_values
ADD COLUMN menu_id INTEGER;
-- 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
-- UNIQUE 제약조건 수정 (menu_id 추가)
ALTER TABLE table_column_category_values
DROP CONSTRAINT IF EXISTS unique_category_value;
ALTER TABLE table_column_category_values
ADD CONSTRAINT unique_category_value
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
-- 인덱스 추가
CREATE INDEX idx_category_value_menu
ON table_column_category_values(menu_id, table_name, column_name, company_code);
```
### 필드 설명
| 필드 | 설명 | 예시 |
| -------------- | ------------------------ | --------------------- |
| `table_name` | 어떤 테이블의 컬럼인지 | `customer_info` |
| `column_name` | 어떤 컬럼의 값인지 | `customer_type` |
| `menu_id` | 어느 메뉴에서 생성했는지 | `204` (공통코드 관리) |
| `company_code` | 멀티테넌시 | `COMPANY_A` |
---
## 🔧 백엔드 구현
### 1. 메뉴 스코프 로직 추가
#### 형제 메뉴 조회 함수
```typescript
// backend-node/src/services/menuService.ts
/**
* 메뉴의 형제 메뉴 ID 목록 조회
* (같은 부모를 가진 메뉴들)
*/
export async function getSiblingMenuIds(menuId: number): Promise<number[]> {
const pool = getPool();
// 1. 현재 메뉴의 부모 찾기
const parentQuery = `
SELECT parent_id FROM menu_info WHERE menu_id = $1
`;
const parentResult = await pool.query(parentQuery, [menuId]);
if (parentResult.rows.length === 0) {
return [menuId]; // 메뉴가 없으면 자기 자신만
}
const parentId = parentResult.rows[0].parent_id;
if (!parentId || parentId === 0) {
// 최상위 메뉴인 경우 자기 자신만
return [menuId];
}
// 2. 같은 부모를 가진 형제 메뉴들 조회
const siblingsQuery = `
SELECT menu_id FROM menu_info WHERE parent_id = $1
`;
const siblingsResult = await pool.query(siblingsQuery, [parentId]);
return siblingsResult.rows.map((row) => row.menu_id);
}
```
### 2. 카테고리 값 조회 API 수정
#### 서비스 로직 수정
```typescript
// backend-node/src/services/tableCategoryValueService.ts
/**
* 카테고리 값 목록 조회 (메뉴 스코프 적용)
*/
async getCategoryValues(
tableName: string,
columnName: string,
menuId: number, // ← 추가
companyCode: string,
includeInactive: boolean = false
): Promise<TableCategoryValue[]> {
logger.info("카테고리 값 조회 (메뉴 스코프)", {
tableName,
columnName,
menuId,
companyCode,
});
const pool = getPool();
// 1. 형제 메뉴 ID 조회
const siblingMenuIds = await getSiblingMenuIds(menuId);
logger.info("형제 메뉴 ID 목록", { menuId, siblingMenuIds });
// 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_id AS "menuId",
created_at AS "createdAt",
created_by AS "createdBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
${!includeInactive ? 'AND is_active = true' : ''}
ORDER BY value_order, value_label
`;
params = [tableName, columnName, siblingMenuIds];
} 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_id AS "menuId",
created_at AS "createdAt",
created_by AS "createdBy"
FROM table_column_category_values
WHERE table_name = $1
AND column_name = $2
AND menu_id = ANY($3) -- ← 형제 메뉴 포함
AND company_code = $4 -- ← 회사별 필터링
${!includeInactive ? 'AND is_active = true' : ''}
ORDER BY value_order, value_label
`;
params = [tableName, columnName, siblingMenuIds, companyCode];
}
const result = await pool.query(query, params);
logger.info(`카테고리 값 ${result.rows.length}개 조회 완료`);
return result.rows;
}
```
### 3. 카테고리 값 추가 API 수정
```typescript
/**
* 카테고리 값 추가 (menu_id 저장)
*/
async addCategoryValue(
value: TableCategoryValue,
menuId: number, // ← 추가
companyCode: string,
userId: string
): Promise<TableCategoryValue> {
logger.info("카테고리 값 추가 (메뉴 스코프)", {
tableName: value.tableName,
columnName: value.columnName,
valueCode: value.valueCode,
menuId,
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_id, -- ← menu_id 추가
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_id AS "menuId",
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,
menuId, // ← 카테고리 관리 화면의 menu_id
userId,
]);
logger.info("카테고리 값 추가 성공", {
valueId: result.rows[0].valueId,
menuId,
});
return result.rows[0];
}
```
### 4. 컨트롤러 수정
```typescript
// backend-node/src/controllers/tableCategoryValueController.ts
/**
* 카테고리 값 목록 조회
*/
export async function getCategoryValues(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { menuId, includeInactive } = req.query; // ← menuId 추가
const companyCode = req.user!.companyCode;
if (!menuId) {
res.status(400).json({
success: false,
message: "menuId는 필수입니다",
});
return;
}
const service = new TableCategoryValueService();
const values = await service.getCategoryValues(
tableName,
columnName,
Number(menuId), // ← menuId 전달
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<void> {
try {
const { menuId, ...value } = req.body; // ← menuId 추가
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
if (!menuId) {
res.status(400).json({
success: false,
message: "menuId는 필수입니다",
});
return;
}
const service = new TableCategoryValueService();
const newValue = await service.addCategoryValue(
value,
menuId, // ← menuId 전달
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,
menuId: number, // ← 추가
includeInactive: boolean = false
) {
try {
const response = await apiClient.get<{
success: boolean;
data: TableCategoryValue[];
}>(`/table-categories/${tableName}/${columnName}/values`, {
params: {
menuId, // ← menuId 쿼리 파라미터 추가
includeInactive,
},
});
return response.data;
} catch (error: any) {
console.error("카테고리 값 조회 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 카테고리 값 추가
*/
export async function addCategoryValue(
value: TableCategoryValue,
menuId: number // ← 추가
) {
try {
const response = await apiClient.post<{
success: boolean;
data: TableCategoryValue;
}>("/table-categories/values", {
...value,
menuId, // ← menuId 포함
});
return response.data;
} catch (error: any) {
console.error("카테고리 값 추가 실패:", error);
return { success: false, error: error.message };
}
}
```
### 2. CategoryColumnList 컴포넌트 수정
```typescript
// frontend/components/table-category/CategoryColumnList.tsx
interface CategoryColumnListProps {
tableName: string;
menuId: number; // ← 추가
selectedColumn: string | null;
onColumnSelect: (columnName: string, columnLabel: string) => void;
}
export function CategoryColumnList({
tableName,
menuId, // ← 추가
selectedColumn,
onColumnSelect,
}: CategoryColumnListProps) {
const [columns, setColumns] = useState<CategoryColumn[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryColumns();
}, [tableName, menuId]); // ← menuId 의존성 추가
const loadCategoryColumns = async () => {
setIsLoading(true);
try {
// table_type_columns에서 input_type='category'인 컬럼 조회
const response = await apiClient.get(
`/table-management/tables/${tableName}/columns`
);
const allColumns = Array.isArray(response.data)
? response.data
: response.data.data?.columns || [];
// category 타입만 필터링
const categoryColumns = allColumns.filter(
(col: any) =>
col.inputType === "category" || col.input_type === "category"
);
const columnsWithCount = await Promise.all(
categoryColumns.map(async (col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || colName;
// 각 컬럼의 값 개수 가져오기 (menuId 전달)
let valueCount = 0;
try {
const valuesResult = await getCategoryValues(
tableName,
colName,
menuId, // ← menuId 전달
false
);
if (valuesResult.success && valuesResult.data) {
valueCount = valuesResult.data.length;
}
} catch (error) {
console.error(`항목 개수 조회 실패 (${colName}):`, error);
}
return {
columnName: colName,
columnLabel: colLabel,
inputType: col.inputType || col.input_type,
valueCount,
};
})
);
setColumns(columnsWithCount);
// 첫 번째 컬럼 자동 선택
if (columnsWithCount.length > 0 && !selectedColumn) {
const firstCol = columnsWithCount[0];
onColumnSelect(firstCol.columnName, firstCol.columnLabel);
}
} catch (error) {
console.error("❌ 카테고리 컬럼 조회 실패:", error);
setColumns([]);
} finally {
setIsLoading(false);
}
};
// ... 나머지 렌더링 로직
}
```
### 3. CategoryValueManager 컴포넌트 수정
```typescript
// frontend/components/table-category/CategoryValueManager.tsx
interface CategoryValueManagerProps {
tableName: string;
columnName: string;
menuId: number; // ← 추가
columnLabel?: string;
onValueCountChange?: (count: number) => void;
}
export function CategoryValueManager({
tableName,
columnName,
menuId, // ← 추가
columnLabel,
onValueCountChange,
}: CategoryValueManagerProps) {
const [values, setValues] = useState<TableCategoryValue[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadCategoryValues();
}, [tableName, columnName, menuId]); // ← menuId 의존성 추가
const loadCategoryValues = async () => {
setIsLoading(true);
try {
const response = await getCategoryValues(
tableName,
columnName,
menuId, // ← menuId 전달
false
);
if (response.success && response.data) {
setValues(response.data);
onValueCountChange?.(response.data.length);
}
} catch (error) {
console.error("카테고리 값 조회 실패:", error);
} finally {
setIsLoading(false);
}
};
const handleAddValue = async (newValue: TableCategoryValue) => {
try {
const response = await addCategoryValue(
{
...newValue,
tableName,
columnName,
},
menuId // ← menuId 전달
);
if (response.success) {
loadCategoryValues();
toast.success("카테고리 값이 추가되었습니다");
}
} catch (error) {
console.error("카테고리 값 추가 실패:", error);
toast.error("카테고리 값 추가 중 오류가 발생했습니다");
}
};
// ... 나머지 CRUD 로직 (menuId를 항상 포함)
}
```
### 4. 화면관리 시스템에서 menuId 전달
#### 화면 디자이너에서 menuId 추출
```typescript
// frontend/components/screen/ScreenDesigner.tsx
export function ScreenDesigner() {
const [selectedScreen, setSelectedScreen] = useState<Screen | null>(null);
// 선택된 화면의 menuId 추출
const currentMenuId = selectedScreen?.menuId;
// CategoryWidget 렌더링 시 menuId 전달
return (
<div>
{/* ... */}
<CategoryWidget
tableName={selectedScreen?.tableName}
menuId={currentMenuId} // ← menuId 전달
/>
</div>
);
}
```
#### CategoryWidget 컴포넌트 (신규 또는 수정)
```typescript
// frontend/components/screen/widgets/CategoryWidget.tsx
interface CategoryWidgetProps {
tableName: string;
menuId: number; // ← 추가
}
export function CategoryWidget({ tableName, menuId }: CategoryWidgetProps) {
const [selectedColumn, setSelectedColumn] = useState<string | null>(null);
const [selectedColumnLabel, setSelectedColumnLabel] = useState<string>("");
const handleColumnSelect = (columnName: string, columnLabel: string) => {
setSelectedColumn(columnName);
setSelectedColumnLabel(columnLabel);
};
return (
<div className="flex h-full gap-6">
{/* 좌측: 카테고리 컬럼 리스트 */}
<div className="w-[30%] border-r pr-6">
<CategoryColumnList
tableName={tableName}
menuId={menuId} // ← menuId 전달
selectedColumn={selectedColumn}
onColumnSelect={handleColumnSelect}
/>
</div>
{/* 우측: 카테고리 값 관리 */}
<div className="w-[70%]">
{selectedColumn ? (
<CategoryValueManager
tableName={tableName}
columnName={selectedColumn}
menuId={menuId} // ← menuId 전달
columnLabel={selectedColumnLabel}
/>
) : (
<div className="flex items-center justify-center py-12 text-center">
<p className="text-sm text-muted-foreground">
좌측에서 카테고리 컬럼을 선택하세요
</p>
</div>
)}
</div>
</div>
);
}
```
---
## 🔄 기존 데이터 마이그레이션
### 마이그레이션 스크립트
```sql
-- db/migrations/047_add_menu_id_to_category_values.sql
-- 1. menu_id 컬럼 추가 (NULL 허용)
ALTER TABLE table_column_category_values
ADD COLUMN IF NOT EXISTS menu_id INTEGER;
-- 2. 기존 데이터에 임시 menu_id 설정
-- (관리자가 수동으로 올바른 menu_id로 변경해야 함)
UPDATE table_column_category_values
SET menu_id = 1
WHERE menu_id IS NULL;
-- 3. menu_id를 NOT NULL로 변경
ALTER TABLE table_column_category_values
ALTER COLUMN menu_id SET NOT NULL;
-- 4. 외래키 추가
ALTER TABLE table_column_category_values
ADD CONSTRAINT fk_category_value_menu
FOREIGN KEY (menu_id) REFERENCES menu_info(menu_id);
-- 5. UNIQUE 제약조건 재생성
ALTER TABLE table_column_category_values
DROP CONSTRAINT IF EXISTS unique_category_value;
ALTER TABLE table_column_category_values
ADD CONSTRAINT unique_category_value
UNIQUE (table_name, column_name, value_code, menu_id, company_code);
-- 6. 인덱스 추가
CREATE INDEX idx_category_value_menu
ON table_column_category_values(menu_id, table_name, column_name, company_code);
COMMENT ON COLUMN table_column_category_values.menu_id IS '카테고리를 생성한 메뉴 ID (형제 메뉴에서 공유)';
```
---
## 📊 사용 시나리오
### 시나리오: 영업관리 공통코드 관리
#### 1단계: 메뉴 구조
```
영업관리 (parent_id: 0, menu_id: 200)
├── 고객관리 (parent_id: 200, menu_id: 201) - customer_info 테이블
├── 계약관리 (parent_id: 200, menu_id: 202) - contract_info 테이블
├── 주문관리 (parent_id: 200, menu_id: 203) - order_info 테이블
└── 공통코드 관리 (parent_id: 200, menu_id: 204) - 카테고리 관리 전용
```
#### 2단계: 카테고리 관리 화면 생성
1. **메뉴 등록**: 영업관리 > 공통코드 관리 (menu_id: 204)
2. **화면 생성**: 화면관리 시스템에서 화면 생성
3. **테이블 선택**: 영업관리에서 사용할 **아무 테이블** (예: `customer_info`)
- 테이블 선택은 컬럼 목록을 가져오기 위한 것일 뿐
- 실제 스코프는 `menu_id`로 결정됨
4. **위젯 배치**: 카테고리 관리 위젯 드래그앤드롭
#### 3단계: 카테고리 값 등록
1. **좌측 패널**: `customer_info` 테이블의 카테고리 컬럼 표시
- `customer_type` (고객 유형)
- `customer_grade` (고객 등급)
2. **컬럼 선택**: `customer_type` 클릭
3. **우측 패널**: 카테고리 값 관리
- 추가 버튼 클릭
- 코드: `REGULAR`, 라벨: `일반 고객`
- 색상: `#3b82f6`
- **저장 시 `menu_id = 204`로 자동 저장됨**
#### 4단계: 다른 화면에서 사용
##### ✅ 형제 메뉴에서 사용 가능
**고객관리 화면** (menu_id: 201):
- `customer_type` 컬럼을 category-select 위젯으로 배치
- 드롭다운에 `일반 고객`, `VIP 고객` 등 표시됨 ✅
- **이유**: 201과 204는 같은 부모(200)를 가진 형제 메뉴
**계약관리 화면** (menu_id: 202):
- `contract_info` 테이블에 `customer_type` 컬럼이 있다면
- 동일한 카테고리 값 사용 가능 ✅
- **이유**: 202와 204도 형제 메뉴
**주문관리 화면** (menu_id: 203):
- `order_info` 테이블에 `customer_type` 컬럼이 있다면
- 동일한 카테고리 값 사용 가능 ✅
- **이유**: 203과 204도 형제 메뉴
##### ❌ 다른 부모 메뉴에서 사용 불가
**구매관리 > 발주관리** (parent_id: 300):
- `purchase_orders` 테이블에 `customer_type` 컬럼이 있어도
- 영업관리의 카테고리는 표시되지 않음 ❌
- **이유**: 다른 부모 메뉴이므로 스코프가 다름
- 구매관리는 자체 카테고리를 별도로 생성해야 함
---
## 📝 구현 순서
### Phase 1: 데이터베이스 마이그레이션 (30분)
1. ✅ 마이그레이션 파일 작성 (`047_add_menu_id_to_category_values.sql`)
2. ⏳ DB 마이그레이션 실행
3. ⏳ 기존 데이터 임시 menu_id 설정 (관리자 수동 정리 필요)
### Phase 2: 백엔드 구현 (2-3시간)
4. ⏳ `menuService.ts``getSiblingMenuIds()` 함수 추가
5. ⏳ `tableCategoryValueService.ts`에 menu_id 로직 추가
- `getCategoryValues()` 메서드에 menuId 파라미터 추가
- `addCategoryValue()` 메서드에 menuId 파라미터 추가
6. ⏳ `tableCategoryValueController.ts` 수정
- 쿼리 파라미터에서 menuId 추출
- 서비스 호출 시 menuId 전달
7. ⏳ 백엔드 테스트
### Phase 3: 프론트엔드 API 클라이언트 (30분)
8. ⏳ `frontend/lib/api/tableCategoryValue.ts` 수정
- `getCategoryValues()` 함수에 menuId 파라미터 추가
- `addCategoryValue()` 함수에 menuId 파라미터 추가
### Phase 4: 프론트엔드 컴포넌트 (2-3시간)
9. ⏳ `CategoryColumnList.tsx` 수정
- props에 `menuId` 추가
- `getCategoryValues()` 호출 시 menuId 전달
10. ⏳ `CategoryValueManager.tsx` 수정
- props에 `menuId` 추가
- 모든 API 호출 시 menuId 전달
11. ⏳ `CategoryWidget.tsx` 수정 또는 신규 생성
- `menuId` prop 추가
- 하위 컴포넌트에 menuId 전달
### Phase 5: 화면관리 시스템 통합 (1-2시간)
12. ⏳ 화면 정보에서 menuId 추출 로직 추가
13. ⏳ CategoryWidget에 menuId 전달
14. ⏳ 카테고리 관리 화면 테스트
### Phase 6: 테스트 및 문서화 (1시간)
15. ⏳ 전체 플로우 테스트
16. ⏳ 메뉴 스코프 동작 검증
17. ⏳ 사용 가이드 작성
---
## 🧪 테스트 체크리스트
### 백엔드 테스트
- [ ] `getSiblingMenuIds()` 함수가 올바른 형제 메뉴 반환
- [ ] 최상위 메뉴의 경우 자기 자신만 반환
- [ ] 카테고리 값 조회 시 형제 메뉴의 값도 포함
- [ ] 다른 부모 메뉴의 카테고리는 조회되지 않음
- [ ] 멀티테넌시 필터링 정상 작동
### 프론트엔드 테스트
- [ ] 카테고리 컬럼 목록 정상 표시
- [ ] 카테고리 값 목록 정상 표시 (형제 메뉴 포함)
- [ ] 카테고리 값 추가 시 menuId 포함
- [ ] 카테고리 값 수정/삭제 정상 작동
### 통합 테스트
- [ ] 영업관리 > 공통코드 관리에서 카테고리 생성
- [ ] 영업관리 > 고객관리에서 카테고리 사용 가능
- [ ] 영업관리 > 계약관리에서 카테고리 사용 가능
- [ ] 구매관리에서는 영업관리 카테고리 사용 불가
---
## 📦 예상 소요 시간
| Phase | 작업 내용 | 예상 시간 |
| ---------------- | ------------------- | ------------ |
| Phase 1 | DB 마이그레이션 | 30분 |
| Phase 2 | 백엔드 구현 | 2-3시간 |
| Phase 3 | API 클라이언트 | 30분 |
| Phase 4 | 프론트엔드 컴포넌트 | 2-3시간 |
| Phase 5 | 화면관리 통합 | 1-2시간 |
| Phase 6 | 테스트 및 문서 | 1시간 |
| **총 예상 시간** | | **7-11시간** |
---
## 💡 이점
### 1. 메뉴별 독립 관리
- 영업관리, 구매관리, 생산관리 등 각 부서별 카테고리 독립 관리
- 부서 간 카테고리 충돌 방지
### 2. 형제 메뉴 간 공유
- 같은 부서의 화면들이 카테고리 공유
- 중복 생성 불필요
### 3. 테이블 독립성
- 테이블이 달라도 같은 카테고리 사용 가능
- 테이블 구조 변경에 영향 없음
### 4. 직관적인 관리
- 메뉴 구조가 곧 카테고리 스코프
- 이해하기 쉬운 권한 체계
---
## 🚀 다음 단계
### 1. 계획 승인 후 즉시 구현 시작
이 계획서를 검토하고 승인받으면 바로 구현을 시작합니다.
### 2. 채번규칙 시스템도 동일하게 전환
카테고리 시스템 전환이 완료되면, 채번규칙 시스템도 동일한 메뉴 기반 스코프로 전환합니다.
### 3. 공통 유틸리티 함수 재사용
`getSiblingMenuIds()` 함수는 카테고리와 채번규칙 모두에서 재사용 가능합니다.
---
이 계획서대로 구현하면 영업관리 전체의 공통코드를 효과적으로 관리할 수 있습니다.
바로 구현을 시작할까요?

File diff suppressed because it is too large Load Diff