Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream

This commit is contained in:
dohyeons 2025-11-13 17:59:41 +09:00
commit ec26aa1bac
46 changed files with 6849 additions and 428 deletions

View File

@ -1127,12 +1127,16 @@ export async function saveMenu(
const objid = Date.now(); // 고유 ID 생성 const objid = Date.now(); // 고유 ID 생성
const companyCode = requestCompanyCode || userCompanyCode; const companyCode = requestCompanyCode || userCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
const [savedMenu] = await query<any>( const [savedMenu] = await query<any>(
`INSERT INTO menu_info ( `INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, regdate, status, seq, menu_url, menu_desc, writer, regdate, status,
system_name, company_code, lang_key, lang_key_desc system_name, company_code, lang_key, lang_key_desc, screen_code
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *`, RETURNING *`,
[ [
objid, objid,
@ -1141,7 +1145,7 @@ export async function saveMenu(
menuData.menuNameKor, menuData.menuNameKor,
menuData.menuNameEng || null, menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null, menuData.seq ? Number(menuData.seq) : null,
menuData.menuUrl || null, menuUrl,
menuData.menuDesc || null, menuData.menuDesc || null,
req.user?.userId || "admin", req.user?.userId || "admin",
new Date(), new Date(),
@ -1150,6 +1154,7 @@ export async function saveMenu(
companyCode, companyCode,
menuData.langKey || null, menuData.langKey || null,
menuData.langKeyDesc || null, menuData.langKeyDesc || null,
screenCode,
] ]
); );
@ -1274,6 +1279,10 @@ export async function updateMenu(
const companyCode = requestCompanyCode; const companyCode = requestCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
// Raw Query를 사용한 메뉴 수정 // Raw Query를 사용한 메뉴 수정
const [updatedMenu] = await query<any>( const [updatedMenu] = await query<any>(
`UPDATE menu_info SET `UPDATE menu_info SET
@ -1288,8 +1297,9 @@ export async function updateMenu(
system_name = $9, system_name = $9,
company_code = $10, company_code = $10,
lang_key = $11, lang_key = $11,
lang_key_desc = $12 lang_key_desc = $12,
WHERE objid = $13 screen_code = $13
WHERE objid = $14
RETURNING *`, RETURNING *`,
[ [
menuData.menuType ? Number(menuData.menuType) : null, menuData.menuType ? Number(menuData.menuType) : null,
@ -1297,17 +1307,29 @@ export async function updateMenu(
menuData.menuNameKor, menuData.menuNameKor,
menuData.menuNameEng || null, menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null, menuData.seq ? Number(menuData.seq) : null,
menuData.menuUrl || null, menuUrl,
menuData.menuDesc || null, menuData.menuDesc || null,
menuData.status || "active", menuData.status || "active",
menuData.systemName || null, menuData.systemName || null,
companyCode, companyCode,
menuData.langKey || null, menuData.langKey || null,
menuData.langKeyDesc || null, menuData.langKeyDesc || null,
screenCode,
Number(menuId), Number(menuId),
] ]
); );
// menu_url이 비어있으면 화면 할당도 해제 (screen_menu_assignments의 is_active를 'N'으로)
if (!menuUrl) {
await query(
`UPDATE screen_menu_assignments
SET is_active = 'N', updated_date = NOW()
WHERE menu_objid = $1 AND company_code = $2`,
[Number(menuId), companyCode]
);
logger.info("화면 할당 비활성화", { menuId, companyCode });
}
logger.info("메뉴 수정 성공", { updatedMenu }); logger.info("메뉴 수정 성공", { updatedMenu });
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
@ -2595,6 +2617,72 @@ export const createCompany = async (
} }
}; };
/**
* GET /api/admin/companies/:companyCode
* API
*/
export const getCompanyByCode = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("회사 정보 조회 요청", {
companyCode,
user: req.user,
});
// Raw Query로 회사 정보 조회
const company = await queryOne<any>(
`SELECT * FROM company_mng WHERE company_code = $1`,
[companyCode]
);
if (!company) {
res.status(404).json({
success: false,
message: "해당 회사를 찾을 수 없습니다.",
errorCode: "COMPANY_NOT_FOUND",
});
return;
}
logger.info("회사 정보 조회 성공", {
companyCode: company.company_code,
companyName: company.company_name,
});
const response = {
success: true,
message: "회사 정보 조회 성공",
data: {
companyCode: company.company_code,
companyName: company.company_name,
businessRegistrationNumber: company.business_registration_number,
representativeName: company.representative_name,
representativePhone: company.representative_phone,
email: company.email,
website: company.website,
address: company.address,
status: company.status,
writer: company.writer,
regdate: company.regdate,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode });
res.status(500).json({
success: false,
message: "회사 정보 조회 중 오류가 발생했습니다.",
errorCode: "COMPANY_GET_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/** /**
* PUT /api/admin/companies/:companyCode * PUT /api/admin/companies/:companyCode
* API * API

View File

@ -28,6 +28,7 @@ export class EntityJoinController {
additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열) additionalJoinColumns, // 추가 조인 컬럼 정보 (JSON 문자열)
screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열) screenEntityConfigs, // 화면별 엔티티 설정 (JSON 문자열)
autoFilter, // 🔒 멀티테넌시 자동 필터 autoFilter, // 🔒 멀티테넌시 자동 필터
dataFilter, // 🆕 데이터 필터 (JSON 문자열)
userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함 userLang, // userLang은 별도로 분리하여 search에 포함되지 않도록 함
...otherParams ...otherParams
} = req.query; } = req.query;
@ -111,6 +112,19 @@ export class EntityJoinController {
} }
} }
// 🆕 데이터 필터 처리
let parsedDataFilter: any = undefined;
if (dataFilter) {
try {
parsedDataFilter =
typeof dataFilter === "string" ? JSON.parse(dataFilter) : dataFilter;
logger.info("데이터 필터 파싱 완료:", parsedDataFilter);
} catch (error) {
logger.warn("데이터 필터 파싱 오류:", error);
parsedDataFilter = undefined;
}
}
const result = await tableManagementService.getTableDataWithEntityJoins( const result = await tableManagementService.getTableDataWithEntityJoins(
tableName, tableName,
{ {
@ -126,6 +140,7 @@ export class EntityJoinController {
enableEntityJoin === "true" || enableEntityJoin === true, enableEntityJoin === "true" || enableEntityJoin === true,
additionalJoinColumns: parsedAdditionalJoinColumns, additionalJoinColumns: parsedAdditionalJoinColumns,
screenEntityConfigs: parsedScreenEntityConfigs, screenEntityConfigs: parsedScreenEntityConfigs,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 전달
} }
); );

View File

@ -5,11 +5,23 @@ import { AuthenticatedRequest } from "../types/auth";
// 화면 목록 조회 // 화면 목록 조회
export const getScreens = async (req: AuthenticatedRequest, res: Response) => { export const getScreens = async (req: AuthenticatedRequest, res: Response) => {
try { try {
const { companyCode } = req.user as any; const userCompanyCode = (req.user as any).companyCode;
const { page = 1, size = 20, searchTerm } = req.query; const { page = 1, size = 20, searchTerm, companyCode } = req.query;
// 쿼리 파라미터로 companyCode가 전달되면 해당 회사의 화면 조회 (최고 관리자 전용)
// 아니면 현재 사용자의 companyCode 사용
const targetCompanyCode = (companyCode as string) || userCompanyCode;
// 최고 관리자가 아닌 경우 자신의 회사 코드만 사용 가능
if (userCompanyCode !== "*" && targetCompanyCode !== userCompanyCode) {
return res.status(403).json({
success: false,
message: "다른 회사의 화면을 조회할 권한이 없습니다.",
});
}
const result = await screenManagementService.getScreensByCompany( const result = await screenManagementService.getScreensByCompany(
companyCode, targetCompanyCode,
parseInt(page as string), parseInt(page as string),
parseInt(size as string) parseInt(size as string)
); );
@ -325,7 +337,118 @@ export const bulkPermanentDeleteScreens = async (
} }
}; };
// 화면 복사 // 연결된 모달 화면 감지 (화면 복사 전 확인)
export const detectLinkedScreens = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const linkedScreens = await screenManagementService.detectLinkedModalScreens(
parseInt(id)
);
res.json({
success: true,
data: linkedScreens,
message: linkedScreens.length > 0
? `${linkedScreens.length}개의 연결된 모달 화면을 감지했습니다.`
: "연결된 모달 화면이 없습니다.",
});
} catch (error: any) {
console.error("연결된 화면 감지 실패:", error);
res.status(500).json({
success: false,
message: error.message || "연결된 화면 감지에 실패했습니다.",
});
}
};
// 화면명 중복 체크
export const checkDuplicateScreenName = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode, screenName } = req.body;
if (!companyCode || !screenName) {
res.status(400).json({
success: false,
message: "companyCode와 screenName은 필수입니다.",
});
return;
}
const isDuplicate =
await screenManagementService.checkDuplicateScreenName(
companyCode,
screenName
);
res.json({
success: true,
data: { isDuplicate },
message: isDuplicate
? "이미 존재하는 화면명입니다."
: "사용 가능한 화면명입니다.",
});
} catch (error: any) {
console.error("화면명 중복 체크 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면명 중복 체크에 실패했습니다.",
});
}
};
// 화면 일괄 복사 (메인 + 모달 화면들)
export const copyScreenWithModals = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const { mainScreen, modalScreens, targetCompanyCode } = req.body;
const { companyCode, userId } = req.user as any;
if (!mainScreen || !mainScreen.screenName || !mainScreen.screenCode) {
res.status(400).json({
success: false,
message: "메인 화면 정보(screenName, screenCode)가 필요합니다.",
});
return;
}
const result = await screenManagementService.copyScreenWithModals({
sourceScreenId: parseInt(id),
companyCode,
userId,
targetCompanyCode, // 최고 관리자가 다른 회사로 복사할 때 사용
mainScreen: {
screenName: mainScreen.screenName,
screenCode: mainScreen.screenCode,
description: mainScreen.description,
},
modalScreens: modalScreens || [],
});
res.json({
success: true,
data: result,
message: `화면 복사가 완료되었습니다. (메인 1개 + 모달 ${result.modalScreens.length}개)`,
});
} catch (error: any) {
console.error("화면 일괄 복사 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 일괄 복사에 실패했습니다.",
});
}
};
// 화면 복사 (단일 - 하위 호환용)
export const copyScreen = async ( export const copyScreen = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,
res: Response res: Response
@ -495,6 +618,50 @@ export const generateScreenCode = async (
} }
}; };
// 여러 개의 화면 코드 일괄 생성
export const generateMultipleScreenCodes = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { companyCode, count } = req.body;
if (!companyCode || typeof companyCode !== "string") {
res.status(400).json({
success: false,
message: "회사 코드(companyCode)는 필수입니다.",
});
return;
}
if (!count || typeof count !== "number" || count < 1 || count > 100) {
res.status(400).json({
success: false,
message: "count는 1~100 사이의 숫자여야 합니다.",
});
return;
}
const screenCodes =
await screenManagementService.generateMultipleScreenCodes(
companyCode,
count
);
res.json({
success: true,
data: { screenCodes },
message: `${count}개의 화면 코드가 생성되었습니다.`,
});
} catch (error: any) {
console.error("화면 코드 일괄 생성 실패:", error);
res.status(500).json({
success: false,
message: error.message || "화면 코드 일괄 생성에 실패했습니다.",
});
}
};
// 화면-메뉴 할당 // 화면-메뉴 할당
export const assignScreenToMenu = async ( export const assignScreenToMenu = async (
req: AuthenticatedRequest, req: AuthenticatedRequest,

View File

@ -268,3 +268,236 @@ export const reorderCategoryValues = async (req: AuthenticatedRequest, res: Resp
} }
}; };
// ================================================
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
// ================================================
/**
*
*
* GET /api/categories/column-mapping/:tableName/:menuObjid
*
* .
*
* @returns { logical_column: physical_column }
*/
export const getColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, menuObjid } = req.params;
if (!tableName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName과 menuObjid는 필수입니다",
});
}
logger.info("컬럼 매핑 조회", {
tableName,
menuObjid,
companyCode,
});
const mapping = await tableCategoryValueService.getColumnMapping(
tableName,
Number(menuObjid),
companyCode
);
return res.json({
success: true,
data: mapping,
});
} catch (error: any) {
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "컬럼 매핑 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* /
*
* POST /api/categories/column-mapping
*
* Body:
* - tableName: 테이블명
* - logicalColumnName: 논리적 (: status_stock)
* - physicalColumnName: 물리적 (: status)
* - menuObjid: 메뉴 OBJID
* - description: 설명 ()
*/
export const createColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
const {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
} = req.body;
// 입력 검증
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName, logicalColumnName, physicalColumnName, menuObjid는 필수입니다",
});
}
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
const mapping = await tableCategoryValueService.createColumnMapping(
tableName,
logicalColumnName,
physicalColumnName,
Number(menuObjid),
companyCode,
userId,
description
);
return res.status(201).json({
success: true,
data: mapping,
message: "컬럼 매핑이 생성되었습니다",
});
} catch (error: any) {
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 생성 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
*
*
* GET /api/categories/logical-columns/:tableName/:menuObjid
*
* .
* ( )
*/
export const getLogicalColumns = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { tableName, menuObjid } = req.params;
if (!tableName || !menuObjid) {
return res.status(400).json({
success: false,
message: "tableName과 menuObjid는 필수입니다",
});
}
logger.info("논리적 컬럼 목록 조회", {
tableName,
menuObjid,
companyCode,
});
const columns = await tableCategoryValueService.getLogicalColumns(
tableName,
Number(menuObjid),
companyCode
);
return res.json({
success: true,
data: columns,
});
} catch (error: any) {
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "논리적 컬럼 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
*
*
* DELETE /api/categories/column-mapping/:mappingId
*/
export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
const { mappingId } = req.params;
if (!mappingId) {
return res.status(400).json({
success: false,
message: "mappingId는 필수입니다",
});
}
logger.info("컬럼 매핑 삭제", {
mappingId,
companyCode,
});
await tableCategoryValueService.deleteColumnMapping(
Number(mappingId),
companyCode
);
return res.json({
success: true,
message: "컬럼 매핑이 삭제되었습니다",
});
} catch (error: any) {
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
};
/**
* 2
*
* GET /api/categories/second-level-menus
*
*
* 2
*/
export const getSecondLevelMenus = async (req: AuthenticatedRequest, res: Response) => {
try {
const companyCode = req.user!.companyCode;
logger.info("2레벨 메뉴 목록 조회", { companyCode });
const menus = await tableCategoryValueService.getSecondLevelMenus(companyCode);
return res.json({
success: true,
data: menus,
});
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`);
return res.status(500).json({
success: false,
message: "2레벨 메뉴 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
};

View File

@ -742,6 +742,7 @@ export async function getTableData(
sortBy, sortBy,
sortOrder = "asc", sortOrder = "asc",
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달) autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
} = req.body; } = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`); logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
@ -749,6 +750,7 @@ export async function getTableData(
logger.info(`검색 조건:`, search); logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`); logger.info(`정렬: ${sortBy} ${sortOrder}`);
logger.info(`자동 필터:`, autoFilter); // 🆕 logger.info(`자동 필터:`, autoFilter); // 🆕
logger.info(`데이터 필터:`, dataFilter); // 🆕
if (!tableName) { if (!tableName) {
const response: ApiResponse<null> = { const response: ApiResponse<null> = {
@ -796,6 +798,7 @@ export async function getTableData(
search: enhancedSearch, // 🆕 필터가 적용된 search 사용 search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy, sortBy,
sortOrder, sortOrder,
dataFilter, // 🆕 데이터 필터 전달
}); });
logger.info( logger.info(
@ -1657,37 +1660,108 @@ export async function getCategoryColumnsByMenu(
return; return;
} }
// 3. 테이블들의 카테고리 타입 컬럼 조회 (테이블 라벨 포함) // 3. category_column_mapping 테이블 존재 여부 확인
logger.info("🔍 카테고리 컬럼 쿼리 준비", { tableNames, companyCode }); const tableExistsResult = await pool.query(`
SELECT EXISTS (
const columnsQuery = ` SELECT FROM information_schema.tables
SELECT WHERE table_name = 'category_column_mapping'
ttc.table_name AS "tableName", ) as table_exists
COALESCE( `);
tl.table_label, const mappingTableExists = tableExistsResult.rows[0]?.table_exists === true;
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel", let columnsResult;
ttc.column_name AS "columnName",
COALESCE( if (mappingTableExists) {
cl.column_label, // 🆕 category_column_mapping을 사용한 필터링
initcap(replace(ttc.column_name, '_', ' ')) logger.info("🔍 category_column_mapping 기반 카테고리 컬럼 조회", { menuObjid, companyCode });
) AS "columnLabel",
ttc.input_type AS "inputType" // 현재 메뉴와 모든 상위 메뉴의 objid 조회 (재귀)
FROM table_type_columns ttc const ancestorMenuQuery = `
LEFT JOIN column_labels cl WITH RECURSIVE menu_hierarchy AS (
ON ttc.table_name = cl.table_name --
AND ttc.column_name = cl.column_name SELECT objid, parent_obj_id, menu_type
LEFT JOIN table_labels tl FROM menu_info
ON ttc.table_name = tl.table_name WHERE objid = $1
WHERE ttc.table_name = ANY($1)
AND ttc.company_code = $2 UNION ALL
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ttc.column_name --
`; SELECT m.objid, m.parent_obj_id, m.menu_type
FROM menu_info m
logger.info("🔍 카테고리 컬럼 쿼리 실행 중..."); INNER JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
const columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]); WHERE m.parent_obj_id != 0 -- (parent_obj_id=0)
logger.info("✅ 카테고리 컬럼 쿼리 완료", { rowCount: columnsResult.rows.length }); )
SELECT ARRAY_AGG(objid) as menu_objids
FROM menu_hierarchy
`;
const ancestorMenuResult = await pool.query(ancestorMenuQuery, [parseInt(menuObjid)]);
const ancestorMenuObjids = ancestorMenuResult.rows[0]?.menu_objids || [parseInt(menuObjid)];
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ccm.logical_column_name AS "columnName",
COALESCE(
cl.column_label,
initcap(replace(ccm.logical_column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM category_column_mapping ccm
INNER JOIN table_type_columns ttc
ON ccm.table_name = ttc.table_name
AND ccm.physical_column_name = ttc.column_name
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 ccm.table_name = ANY($1)
AND ccm.company_code = $2
AND ccm.menu_objid = ANY($3)
AND ttc.input_type = 'category'
ORDER BY ttc.table_name, ccm.logical_column_name
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode, ancestorMenuObjids]);
logger.info("✅ category_column_mapping 기반 조회 완료", { rowCount: columnsResult.rows.length });
} else {
// 🔄 기존 방식: table_type_columns에서 모든 카테고리 컬럼 조회
logger.info("🔍 레거시 방식: table_type_columns 기반 카테고리 컬럼 조회", { 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
`;
columnsResult = await pool.query(columnsQuery, [tableNames, companyCode]);
logger.info("✅ 레거시 방식 조회 완료", { rowCount: columnsResult.rows.length });
}
logger.info("✅ 카테고리 컬럼 조회 완료", { logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length columnCount: columnsResult.rows.length

View File

@ -19,6 +19,7 @@ import {
saveUser, // 사용자 등록/수정 saveUser, // 사용자 등록/수정
getCompanyList, getCompanyList,
getCompanyListFromDB, // 실제 DB에서 회사 목록 조회 getCompanyListFromDB, // 실제 DB에서 회사 목록 조회
getCompanyByCode, // 회사 단건 조회
createCompany, // 회사 등록 createCompany, // 회사 등록
updateCompany, // 회사 수정 updateCompany, // 회사 수정
deleteCompany, // 회사 삭제 deleteCompany, // 회사 삭제
@ -60,6 +61,7 @@ router.get("/departments", getDepartmentList); // 부서 목록 조회
// 회사 관리 API // 회사 관리 API
router.get("/companies", getCompanyList); router.get("/companies", getCompanyList);
router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회 router.get("/companies/db", getCompanyListFromDB); // 실제 DB에서 회사 목록 조회
router.get("/companies/:companyCode", getCompanyByCode); // 회사 단건 조회
router.post("/companies", createCompany); // 회사 등록 router.post("/companies", createCompany); // 회사 등록
router.put("/companies/:companyCode", updateCompany); // 회사 수정 router.put("/companies/:companyCode", updateCompany); // 회사 수정
router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제 router.delete("/companies/:companyCode", deleteCompany); // 회사 삭제

View File

@ -14,7 +14,7 @@ router.get(
authenticateToken, authenticateToken,
async (req: AuthenticatedRequest, res) => { async (req: AuthenticatedRequest, res) => {
try { try {
const { leftTable, rightTable, leftColumn, rightColumn, leftValue } = const { leftTable, rightTable, leftColumn, rightColumn, leftValue, dataFilter } =
req.query; req.query;
// 입력값 검증 // 입력값 검증
@ -27,6 +27,16 @@ router.get(
}); });
} }
// dataFilter 파싱 (JSON 문자열로 전달됨)
let parsedDataFilter = undefined;
if (dataFilter && typeof dataFilter === "string") {
try {
parsedDataFilter = JSON.parse(dataFilter);
} catch (error) {
console.error("dataFilter 파싱 오류:", error);
}
}
// SQL 인젝션 방지를 위한 검증 // SQL 인젝션 방지를 위한 검증
const tables = [leftTable as string, rightTable as string]; const tables = [leftTable as string, rightTable as string];
const columns = [leftColumn as string, rightColumn as string]; const columns = [leftColumn as string, rightColumn as string];
@ -61,16 +71,18 @@ router.get(
rightColumn, rightColumn,
leftValue, leftValue,
userCompany, userCompany,
dataFilter: parsedDataFilter, // 🆕 데이터 필터 로그
}); });
// 조인 데이터 조회 (회사 코드 전달) // 조인 데이터 조회 (회사 코드 + 데이터 필터 전달)
const result = await dataService.getJoinedData( const result = await dataService.getJoinedData(
leftTable as string, leftTable as string,
rightTable as string, rightTable as string,
leftColumn as string, leftColumn as string,
rightColumn as string, rightColumn as string,
leftValue as string, leftValue as string,
userCompany userCompany,
parsedDataFilter // 🆕 데이터 필터 전달
); );
if (!result.success) { if (!result.success) {

View File

@ -13,13 +13,17 @@ import {
permanentDeleteScreen, permanentDeleteScreen,
getDeletedScreens, getDeletedScreens,
bulkPermanentDeleteScreens, bulkPermanentDeleteScreens,
detectLinkedScreens,
checkDuplicateScreenName,
copyScreen, copyScreen,
copyScreenWithModals,
getTables, getTables,
getTableInfo, getTableInfo,
getTableColumns, getTableColumns,
saveLayout, saveLayout,
getLayout, getLayout,
generateScreenCode, generateScreenCode,
generateMultipleScreenCodes,
assignScreenToMenu, assignScreenToMenu,
getScreensByMenu, getScreensByMenu,
unassignScreenFromMenu, unassignScreenFromMenu,
@ -40,7 +44,10 @@ router.put("/screens/:id", updateScreen);
router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정
router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크
router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동
router.post("/screens/:id/copy", copyScreen); router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지
router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크
router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용)
router.post("/screens/:id/copy-with-modals", copyScreenWithModals); // 메인 + 모달 일괄 복사
// 휴지통 관리 // 휴지통 관리
router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록 router.get("/screens/trash/list", getDeletedScreens); // 휴지통 화면 목록
@ -51,6 +58,9 @@ router.delete("/screens/trash/bulk", bulkPermanentDeleteScreens); // 일괄 영
// 화면 코드 자동 생성 // 화면 코드 자동 생성
router.get("/generate-screen-code/:companyCode", generateScreenCode); router.get("/generate-screen-code/:companyCode", generateScreenCode);
// 여러 개의 화면 코드 일괄 생성
router.post("/generate-screen-codes", generateMultipleScreenCodes);
// 테이블 관리 // 테이블 관리
router.get("/tables", getTables); router.get("/tables", getTables);
router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화) router.get("/tables/:tableName", getTableInfo); // 특정 테이블 정보 조회 (최적화)

View File

@ -7,6 +7,11 @@ import {
deleteCategoryValue, deleteCategoryValue,
bulkDeleteCategoryValues, bulkDeleteCategoryValues,
reorderCategoryValues, reorderCategoryValues,
getColumnMapping,
createColumnMapping,
getLogicalColumns,
deleteColumnMapping,
getSecondLevelMenus,
} from "../controllers/tableCategoryValueController"; } from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -36,5 +41,24 @@ router.post("/values/bulk-delete", bulkDeleteCategoryValues);
// 카테고리 값 순서 변경 // 카테고리 값 순서 변경
router.post("/values/reorder", reorderCategoryValues); router.post("/values/reorder", reorderCategoryValues);
// ================================================
// 컬럼 매핑 관련 라우트 (논리명 ↔ 물리명)
// ================================================
// 2레벨 메뉴 목록 조회 (메뉴 선택용)
router.get("/second-level-menus", getSecondLevelMenus);
// 컬럼 매핑 조회
router.get("/column-mapping/:tableName/:menuObjid", getColumnMapping);
// 논리적 컬럼 목록 조회
router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
// 컬럼 매핑 생성/수정
router.post("/column-mapping", createColumnMapping);
// 컬럼 매핑 삭제
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
export default router; export default router;

View File

@ -14,6 +14,7 @@
* - (company_code = "*") * - (company_code = "*")
*/ */
import { query, queryOne } from "../database/db"; import { query, queryOne } from "../database/db";
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
interface GetTableDataParams { interface GetTableDataParams {
tableName: string; tableName: string;
@ -434,7 +435,8 @@ class DataService {
leftColumn: string, leftColumn: string,
rightColumn: string, rightColumn: string,
leftValue?: string | number, leftValue?: string | number,
userCompany?: string userCompany?: string,
dataFilter?: any // 🆕 데이터 필터
): Promise<ServiceResponse<any[]>> { ): Promise<ServiceResponse<any[]>> {
try { try {
// 왼쪽 테이블 접근 검증 // 왼쪽 테이블 접근 검증
@ -478,6 +480,17 @@ class DataService {
} }
} }
// 🆕 데이터 필터 적용 (우측 패널에 대해, 테이블 별칭 "r" 사용)
if (dataFilter && dataFilter.enabled && dataFilter.filters && dataFilter.filters.length > 0) {
const filterResult = buildDataFilterWhereClause(dataFilter, "r", paramIndex);
if (filterResult.whereClause) {
whereConditions.push(filterResult.whereClause);
values.push(...filterResult.params);
paramIndex += filterResult.params.length;
console.log(`🔍 데이터 필터 적용 (${rightTable}):`, filterResult.whereClause);
}
}
// WHERE 절 추가 // WHERE 절 추가
if (whereConditions.length > 0) { if (whereConditions.length > 0) {
queryText += ` WHERE ${whereConditions.join(" AND ")}`; queryText += ` WHERE ${whereConditions.join(" AND ")}`;

View File

@ -223,12 +223,14 @@ export class EntityJoinService {
const aliasMap = new Map<string, string>(); const aliasMap = new Map<string, string>();
const usedAliasesForColumns = new Set<string>(); const usedAliasesForColumns = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성 // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
// (table_column_category_values는 같은 테이블이라도 sourceColumn마다 별도 JOIN 필요)
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if ( if (
!acc.some( !acc.some(
(existingConfig) => (existingConfig) =>
existingConfig.referenceTable === config.referenceTable existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
) )
) { ) {
acc.push(config); acc.push(config);
@ -237,7 +239,7 @@ export class EntityJoinService {
}, [] as EntityJoinConfig[]); }, [] as EntityJoinConfig[]);
logger.info( logger.info(
`🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블` `🔧 별칭 생성 시작: ${uniqueReferenceTableConfigs.length}개 고유 테이블+컬럼 조합`
); );
uniqueReferenceTableConfigs.forEach((config) => { uniqueReferenceTableConfigs.forEach((config) => {
@ -250,13 +252,16 @@ export class EntityJoinService {
counter++; counter++;
} }
usedAliasesForColumns.add(alias); usedAliasesForColumns.add(alias);
aliasMap.set(config.referenceTable, alias); // 같은 테이블이라도 sourceColumn이 다르면 별도 별칭 생성 (table_column_category_values 대응)
logger.info(`🔧 별칭 생성: ${config.referenceTable}${alias}`); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
logger.info(`🔧 별칭 생성: ${config.referenceTable}.${config.sourceColumn}${alias}`);
}); });
const joinColumns = joinConfigs const joinColumns = joinConfigs
.map((config) => { .map((config) => {
const alias = aliasMap.get(config.referenceTable); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
const displayColumns = config.displayColumns || [ const displayColumns = config.displayColumns || [
config.displayColumn, config.displayColumn,
]; ];
@ -346,14 +351,16 @@ export class EntityJoinService {
// FROM 절 (메인 테이블) // FROM 절 (메인 테이블)
const fromClause = `FROM ${tableName} main`; const fromClause = `FROM ${tableName} main`;
// LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 중복 테이블 제거) // LEFT JOIN 절들 (위에서 생성한 별칭 매핑 사용, 각 sourceColumn마다 별도 JOIN)
const joinClauses = uniqueReferenceTableConfigs const joinClauses = uniqueReferenceTableConfigs
.map((config) => { .map((config) => {
const alias = aliasMap.get(config.referenceTable); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 // table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') { if (config.referenceTable === 'table_column_category_values') {
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}'`; // 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
} }
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
@ -538,12 +545,13 @@ export class EntityJoinService {
const aliasMap = new Map<string, string>(); const aliasMap = new Map<string, string>();
const usedAliases = new Set<string>(); const usedAliases = new Set<string>();
// joinConfigs를 참조 테이블별로 중복 제거하여 별칭 생성 // joinConfigs를 참조 테이블 + 소스 컬럼별로 중복 제거하여 별칭 생성
const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => { const uniqueReferenceTableConfigs = joinConfigs.reduce((acc, config) => {
if ( if (
!acc.some( !acc.some(
(existingConfig) => (existingConfig) =>
existingConfig.referenceTable === config.referenceTable existingConfig.referenceTable === config.referenceTable &&
existingConfig.sourceColumn === config.sourceColumn
) )
) { ) {
acc.push(config); acc.push(config);
@ -561,13 +569,22 @@ export class EntityJoinService {
counter++; counter++;
} }
usedAliases.add(alias); usedAliases.add(alias);
aliasMap.set(config.referenceTable, alias); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
aliasMap.set(aliasKey, alias);
}); });
// JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요) // JOIN 절들 (COUNT에서는 SELECT 컬럼 불필요)
const joinClauses = uniqueReferenceTableConfigs const joinClauses = uniqueReferenceTableConfigs
.map((config) => { .map((config) => {
const alias = aliasMap.get(config.referenceTable); const aliasKey = `${config.referenceTable}:${config.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
// table_column_category_values는 특별한 조인 조건 필요 (회사별 필터링만)
if (config.referenceTable === 'table_column_category_values') {
// 멀티테넌시: 회사 데이터만 사용 (공통 데이터 제외)
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn} AND ${alias}.table_name = '${tableName}' AND ${alias}.column_name = '${config.sourceColumn}' AND ${alias}.company_code = main.company_code AND ${alias}.is_active = true`;
}
return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`; return `LEFT JOIN ${config.referenceTable} ${alias} ON main.${config.sourceColumn} = ${alias}.${config.referenceColumn}`;
}) })
.join("\n"); .join("\n");

View File

@ -539,29 +539,43 @@ export class RoleService {
/** /**
* ( ) * ( )
*/ *
/** * @param companyCode -
* ( ) * - undefined: -
* - "*": ( )
* - "COMPANY_X": ( )
*
* :
* - (company_code = "*") .
* - menu_type = 2 () .
*/ */
static async getAllMenus(companyCode?: string): Promise<any[]> { static async getAllMenus(companyCode?: string): Promise<any[]> {
try { try {
logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode }); logger.info("🔍 전체 메뉴 목록 조회 시작", { companyCode });
let whereConditions: string[] = ["status = 'active'"]; let whereConditions: string[] = [
"status = 'active'",
"menu_type != 2" // 화면 제외, 메뉴만 조회
];
const params: any[] = []; const params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 회사 코드 필터 (선택적) // 회사 코드에 따른 필터링
// 공통 메뉴(*)와 특정 회사 메뉴를 모두 조회 if (companyCode === undefined) {
// 회사 코드 필터 (선택적) // 최고 관리자: 모든 메뉴 조회
if (companyCode) { logger.info("📋 최고 관리자 모드: 모든 메뉴 조회");
// 특정 회사 메뉴만 조회 (공통 메뉴 제외) } else if (companyCode === "*") {
// 공통 메뉴만 조회
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`company_code = $${paramIndex}`);
params.push("*");
paramIndex++;
logger.info("📋 공통 메뉴만 조회");
} else {
// 특정 회사: 해당 회사 메뉴 + 공통 메뉴 조회
whereConditions.push(`(company_code = $${paramIndex} OR company_code = '*')`);
params.push(companyCode); params.push(companyCode);
paramIndex++; paramIndex++;
logger.info("📋 회사 코드 필터 적용 (공통 메뉴 제외)", { companyCode }); logger.info("📋 회사별 필터 적용 (해당 회사 + 공통 메뉴)", { companyCode });
} else {
logger.info("📋 회사 코드 필터 없음 (전체 조회)");
} }
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
@ -573,13 +587,19 @@ export class RoleService {
menu_name_eng AS "menuNameEng", menu_name_eng AS "menuNameEng",
menu_code AS "menuCode", menu_code AS "menuCode",
menu_url AS "menuUrl", menu_url AS "menuUrl",
menu_type AS "menuType", CAST(menu_type AS TEXT) AS "menuType",
parent_obj_id AS "parentObjid", parent_obj_id AS "parentObjid",
seq AS "sortOrder", seq AS "sortOrder",
company_code AS "companyCode" company_code AS "companyCode"
FROM menu_info FROM menu_info
WHERE ${whereClause} WHERE ${whereClause}
ORDER BY seq, menu_name_kor ORDER BY
CASE
WHEN parent_obj_id = 0 OR parent_obj_id IS NULL THEN 0
ELSE 1
END,
seq,
menu_name_kor
`; `;
logger.info("🔍 SQL 쿼리 실행", { logger.info("🔍 SQL 쿼리 실행", {
@ -592,8 +612,9 @@ export class RoleService {
logger.info("✅ 메뉴 목록 조회 성공", { logger.info("✅ 메뉴 목록 조회 성공", {
count: result.length, count: result.length,
companyCode, companyCode: companyCode || "전체",
menus: result.map((m) => ({ companyCodes: [...new Set(result.map((m) => m.companyCode))],
menus: result.slice(0, 5).map((m) => ({
objid: m.objid, objid: m.objid,
name: m.menuName, name: m.menuName,
code: m.menuCode, code: m.menuCode,

View File

@ -23,8 +23,9 @@ interface CopyScreenRequest {
screenName: string; screenName: string;
screenCode: string; screenCode: string;
description?: string; description?: string;
companyCode: string; companyCode: string; // 요청한 사용자의 회사 코드 (인증용)
createdBy: string; userId: string;
targetCompanyCode?: string; // 복사 대상 회사 코드 (최고 관리자 전용)
} }
// 백엔드에서 사용할 테이블 정보 타입 // 백엔드에서 사용할 테이블 정보 타입
@ -1513,6 +1514,7 @@ export class ScreenManagementService {
throw new Error("이미 할당된 화면입니다."); throw new Error("이미 할당된 화면입니다.");
} }
// screen_menu_assignments에 할당 추가
await query( await query(
`INSERT INTO screen_menu_assignments ( `INSERT INTO screen_menu_assignments (
screen_id, menu_objid, company_code, display_order, created_by screen_id, menu_objid, company_code, display_order, created_by
@ -1525,6 +1527,40 @@ export class ScreenManagementService {
assignmentData.createdBy || null, assignmentData.createdBy || null,
] ]
); );
// 화면 정보 조회 (screen_code 가져오기)
const screen = await queryOne<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions WHERE screen_id = $1`,
[screenId]
);
if (screen) {
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 설정)
// 관리자 메뉴인지 확인
const menu = await queryOne<{ menu_type: string }>(
`SELECT menu_type FROM menu_info WHERE objid = $1`,
[assignmentData.menuObjid]
);
const isAdminMenu = menu && (menu.menu_type === "0" || menu.menu_type === "admin");
const menuUrl = isAdminMenu
? `/screens/${screenId}?mode=admin`
: `/screens/${screenId}`;
await query(
`UPDATE menu_info
SET menu_url = $1, screen_code = $2
WHERE objid = $3`,
[menuUrl, screen.screen_code, assignmentData.menuObjid]
);
logger.info("화면 할당 완료 (menu_info 업데이트)", {
screenId,
menuObjid: assignmentData.menuObjid,
menuUrl,
screenCode: screen.screen_code,
});
}
} }
/** /**
@ -1588,11 +1624,26 @@ export class ScreenManagementService {
menuObjid: number, menuObjid: number,
companyCode: string companyCode: string
): Promise<void> { ): Promise<void> {
// screen_menu_assignments에서 할당 삭제
await query( await query(
`DELETE FROM screen_menu_assignments `DELETE FROM screen_menu_assignments
WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`, WHERE screen_id = $1 AND menu_objid = $2 AND company_code = $3`,
[screenId, menuObjid, companyCode] [screenId, menuObjid, companyCode]
); );
// menu_info 테이블도 함께 업데이트 (menu_url과 screen_code 제거)
await query(
`UPDATE menu_info
SET menu_url = NULL, screen_code = NULL
WHERE objid = $1`,
[menuObjid]
);
logger.info("화면 할당 해제 완료 (menu_info 업데이트)", {
screenId,
menuObjid,
companyCode,
});
} }
// ======================================== // ========================================
@ -1841,37 +1892,191 @@ export class ScreenManagementService {
/** /**
* ( + '_' + ) ( Raw Query ) * ( + '_' + ) ( Raw Query )
* 방지: Advisory Lock
*/ */
async generateScreenCode(companyCode: string): Promise<string> { async generateScreenCode(companyCode: string): Promise<string> {
// 해당 회사의 기존 화면 코드들 조회 (Raw Query) return await transaction(async (client) => {
const existingScreens = await query<{ screen_code: string }>( // 회사 코드를 숫자로 변환하여 advisory lock ID로 사용
`SELECT screen_code FROM screen_definitions const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC`, // Advisory lock 획득 (다른 트랜잭션이 같은 회사 코드를 생성하는 동안 대기)
[companyCode, `${companyCode}%`] await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
);
// 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기 // 해당 회사의 기존 화면 코드들 조회
let maxNumber = 0; const existingScreens = await client.query<{ screen_code: string }>(
const pattern = new RegExp( `SELECT screen_code FROM screen_definitions
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$` WHERE company_code = $1 AND screen_code LIKE $2
); ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
);
for (const screen of existingScreens) { // 회사 코드 뒤의 숫자 부분 추출하여 최대값 찾기
const match = screen.screen_code.match(pattern); let maxNumber = 0;
if (match) { const pattern = new RegExp(
const number = parseInt(match[1], 10); `^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
if (number > maxNumber) { );
maxNumber = number;
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
} }
} }
// 다음 순번으로 화면 코드 생성 (3자리 패딩)
const nextNumber = maxNumber + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
const newCode = `${companyCode}_${paddedNumber}`;
console.log(`🔢 화면 코드 생성: ${companyCode}${newCode} (maxNumber: ${maxNumber})`);
return newCode;
// Advisory lock은 트랜잭션 종료 시 자동으로 해제됨
});
}
/**
* ( )
*
*/
async generateMultipleScreenCodes(
companyCode: string,
count: number
): Promise<string[]> {
return await transaction(async (client) => {
// Advisory lock 획득
const lockId = Buffer.from(companyCode).reduce((acc, byte) => acc + byte, 0);
await client.query('SELECT pg_advisory_xact_lock($1)', [lockId]);
// 현재 최대 번호 조회
const existingScreens = await client.query<{ screen_code: string }>(
`SELECT screen_code FROM screen_definitions
WHERE company_code = $1 AND screen_code LIKE $2
ORDER BY screen_code DESC
LIMIT 10`,
[companyCode, `${companyCode}%`]
);
let maxNumber = 0;
const pattern = new RegExp(
`^${companyCode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:_)?(\\d+)$`
);
for (const screen of existingScreens.rows) {
const match = screen.screen_code.match(pattern);
if (match) {
const number = parseInt(match[1], 10);
if (number > maxNumber) {
maxNumber = number;
}
}
}
// count개의 코드를 순차적으로 생성
const codes: string[] = [];
for (let i = 0; i < count; i++) {
const nextNumber = maxNumber + i + 1;
const paddedNumber = nextNumber.toString().padStart(3, "0");
codes.push(`${companyCode}_${paddedNumber}`);
}
console.log(`🔢 화면 코드 일괄 생성 (${count}개): ${companyCode} → [${codes.join(', ')}]`);
return codes;
});
}
/**
*
*
*/
async checkDuplicateScreenName(
companyCode: string,
screenName: string
): Promise<boolean> {
const result = await query<any>(
`SELECT COUNT(*) as count
FROM screen_definitions
WHERE company_code = $1
AND screen_name = $2
AND deleted_date IS NULL`,
[companyCode, screenName]
);
const count = parseInt(result[0]?.count || "0", 10);
return count > 0;
}
/**
*
* popup targetScreenId를
*/
async detectLinkedModalScreens(
screenId: number
): Promise<{ screenId: number; screenName: string; screenCode: string }[]> {
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
const linkedScreenIds = new Set<number>();
// 각 레이아웃에서 버튼의 popup/modal/edit 액션 확인
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (properties?.componentType === "button" || properties?.componentType?.startsWith("button-")) {
const action = properties?.componentConfig?.action;
// popup, modal, edit 액션이고 targetScreenId가 있는 경우
// edit 액션도 수정 폼 모달을 열기 때문에 포함
if ((action?.type === "popup" || action?.type === "modal" || action?.type === "edit") && action?.targetScreenId) {
const targetScreenId = parseInt(action.targetScreenId);
if (!isNaN(targetScreenId)) {
linkedScreenIds.add(targetScreenId);
console.log(`🔗 연결된 모달 화면 발견: screenId=${targetScreenId}, actionType=${action.type} (레이아웃 ${layout.layout_id})`);
}
}
}
} catch (error) {
// JSON 파싱 오류 등은 무시하고 계속 진행
console.warn(`레이아웃 ${layout.layout_id} 파싱 오류:`, error);
}
} }
// 다음 순번으로 화면 코드 생성 (3자리 패딩) // 감지된 화면 ID들의 정보 조회
const nextNumber = maxNumber + 1; if (linkedScreenIds.size === 0) {
const paddedNumber = nextNumber.toString().padStart(3, "0"); return [];
}
return `${companyCode}_${paddedNumber}`; const screenIds = Array.from(linkedScreenIds);
const placeholders = screenIds.map((_, i) => `$${i + 1}`).join(", ");
const linkedScreens = await query<any>(
`SELECT screen_id, screen_name, screen_code
FROM screen_definitions
WHERE screen_id IN (${placeholders})
AND deleted_date IS NULL
ORDER BY screen_name`,
screenIds
);
return linkedScreens.map((s) => ({
screenId: s.screen_id,
screenName: s.screen_name,
screenCode: s.screen_code,
}));
} }
/** /**
@ -1884,11 +2089,31 @@ export class ScreenManagementService {
// 트랜잭션으로 처리 // 트랜잭션으로 처리
return await transaction(async (client) => { return await transaction(async (client) => {
// 1. 원본 화면 정보 조회 // 1. 원본 화면 정보 조회
// 최고 관리자(company_code = "*")는 모든 화면을 조회할 수 있음
let sourceScreenQuery: string;
let sourceScreenParams: any[];
if (copyData.companyCode === "*") {
// 최고 관리자: 모든 회사의 화면 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1
LIMIT 1
`;
sourceScreenParams = [sourceScreenId];
} else {
// 일반 회사: 자신의 회사 화면만 조회 가능
sourceScreenQuery = `
SELECT * FROM screen_definitions
WHERE screen_id = $1 AND company_code = $2
LIMIT 1
`;
sourceScreenParams = [sourceScreenId, copyData.companyCode];
}
const sourceScreens = await client.query<any>( const sourceScreens = await client.query<any>(
`SELECT * FROM screen_definitions sourceScreenQuery,
WHERE screen_id = $1 AND company_code = $2 sourceScreenParams
LIMIT 1`,
[sourceScreenId, copyData.companyCode]
); );
if (sourceScreens.rows.length === 0) { if (sourceScreens.rows.length === 0) {
@ -1897,19 +2122,24 @@ export class ScreenManagementService {
const sourceScreen = sourceScreens.rows[0]; const sourceScreen = sourceScreens.rows[0];
// 2. 화면 코드 중복 체크 // 2. 대상 회사 코드 결정
// copyData.targetCompanyCode가 있으면 사용 (회사 간 복사)
// 없으면 원본과 같은 회사에 복사
const targetCompanyCode = copyData.targetCompanyCode || sourceScreen.company_code;
// 3. 화면 코드 중복 체크 (대상 회사 기준)
const existingScreens = await client.query<any>( const existingScreens = await client.query<any>(
`SELECT screen_id FROM screen_definitions `SELECT screen_id FROM screen_definitions
WHERE screen_code = $1 AND company_code = $2 WHERE screen_code = $1 AND company_code = $2
LIMIT 1`, LIMIT 1`,
[copyData.screenCode, copyData.companyCode] [copyData.screenCode, targetCompanyCode]
); );
if (existingScreens.rows.length > 0) { if (existingScreens.rows.length > 0) {
throw new Error("이미 존재하는 화면 코드입니다."); throw new Error("이미 존재하는 화면 코드입니다.");
} }
// 3. 새 화면 생성 // 4. 새 화면 생성 (대상 회사에 생성)
const newScreenResult = await client.query<any>( const newScreenResult = await client.query<any>(
`INSERT INTO screen_definitions ( `INSERT INTO screen_definitions (
screen_code, screen_name, description, company_code, table_name, screen_code, screen_name, description, company_code, table_name,
@ -1920,12 +2150,12 @@ export class ScreenManagementService {
copyData.screenCode, copyData.screenCode,
copyData.screenName, copyData.screenName,
copyData.description || sourceScreen.description, copyData.description || sourceScreen.description,
copyData.companyCode, targetCompanyCode, // 대상 회사 코드 사용
sourceScreen.table_name, sourceScreen.table_name,
sourceScreen.is_active, sourceScreen.is_active,
copyData.createdBy, copyData.userId,
new Date(), new Date(),
copyData.createdBy, copyData.userId,
new Date(), new Date(),
] ]
); );
@ -2005,6 +2235,165 @@ export class ScreenManagementService {
}; };
}); });
} }
/**
* +
*/
async copyScreenWithModals(data: {
sourceScreenId: number;
companyCode: string;
userId: string;
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
mainScreen: {
screenName: string;
screenCode: string;
description?: string;
};
modalScreens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
}): Promise<{
mainScreen: ScreenDefinition;
modalScreens: ScreenDefinition[];
}> {
const targetCompany = data.targetCompanyCode || data.companyCode;
console.log(`🔄 일괄 복사 시작: 메인(${data.sourceScreenId}) + 모달(${data.modalScreens.length}개) → ${targetCompany}`);
// 1. 메인 화면 복사
const mainScreen = await this.copyScreen(data.sourceScreenId, {
screenName: data.mainScreen.screenName,
screenCode: data.mainScreen.screenCode,
description: data.mainScreen.description || "",
companyCode: data.companyCode,
userId: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
console.log(`✅ 메인 화면 복사 완료: ${mainScreen.screenId} (${mainScreen.screenCode}) @ ${mainScreen.companyCode}`);
// 2. 모달 화면들 복사 (원본 screenId → 새 screenId 매핑)
const modalScreens: ScreenDefinition[] = [];
const screenIdMapping: Map<number, number> = new Map(); // 원본 ID → 새 ID
for (const modalData of data.modalScreens) {
const copiedModal = await this.copyScreen(modalData.sourceScreenId, {
screenName: modalData.screenName,
screenCode: modalData.screenCode,
description: "",
companyCode: data.companyCode,
userId: data.userId,
targetCompanyCode: data.targetCompanyCode, // 대상 회사 코드 전달
});
modalScreens.push(copiedModal);
screenIdMapping.set(modalData.sourceScreenId, copiedModal.screenId);
console.log(
`✅ 모달 화면 복사 완료: ${modalData.sourceScreenId}${copiedModal.screenId} (${copiedModal.screenCode})`
);
}
// 3. 메인 화면의 버튼 액션에서 targetScreenId 업데이트
// 모든 복사가 완료되고 커밋된 후에 실행
console.log(`🔧 버튼 업데이트 시작: 메인 화면 ${mainScreen.screenId}, 매핑:`,
Array.from(screenIdMapping.entries())
);
const updateCount = await this.updateButtonTargetScreenIds(
mainScreen.screenId,
screenIdMapping
);
console.log(`🎉 일괄 복사 완료: 메인(${mainScreen.screenId}) + 모달(${modalScreens.length}개), 버튼 ${updateCount}개 업데이트`);
return {
mainScreen,
modalScreens,
};
}
/**
* targetScreenId를 screenId로
* ( )
*/
private async updateButtonTargetScreenIds(
screenId: number,
screenIdMapping: Map<number, number>
): Promise<number> {
console.log(`🔍 updateButtonTargetScreenIds 호출: screenId=${screenId}, 매핑 개수=${screenIdMapping.size}`);
// 화면의 모든 레이아웃 조회
const layouts = await query<any>(
`SELECT layout_id, properties
FROM screen_layouts
WHERE screen_id = $1
AND component_type = 'component'
AND properties IS NOT NULL`,
[screenId]
);
console.log(`📦 조회된 레이아웃 개수: ${layouts.length}`);
let updateCount = 0;
for (const layout of layouts) {
try {
const properties = layout.properties;
// 버튼 컴포넌트인지 확인
if (
properties?.componentType === "button" ||
properties?.componentType?.startsWith("button-")
) {
const action = properties?.componentConfig?.action;
// targetScreenId가 있는 액션 (popup, modal, edit)
if (
(action?.type === "popup" ||
action?.type === "modal" ||
action?.type === "edit") &&
action?.targetScreenId
) {
const oldScreenId = parseInt(action.targetScreenId);
console.log(`🔍 버튼 발견: layout ${layout.layout_id}, action=${action.type}, targetScreenId=${oldScreenId}`);
// 매핑에 있으면 업데이트
if (screenIdMapping.has(oldScreenId)) {
const newScreenId = screenIdMapping.get(oldScreenId)!;
console.log(`✅ 매핑 발견: ${oldScreenId}${newScreenId}`);
// properties 업데이트
properties.componentConfig.action.targetScreenId =
newScreenId.toString();
// 데이터베이스 업데이트
await query(
`UPDATE screen_layouts
SET properties = $1
WHERE layout_id = $2`,
[JSON.stringify(properties), layout.layout_id]
);
updateCount++;
console.log(
`🔗 버튼 targetScreenId 업데이트: ${oldScreenId}${newScreenId} (layout ${layout.layout_id})`
);
} else {
console.log(`⚠️ 매핑 없음: ${oldScreenId} (업데이트 건너뜀)`);
}
}
}
} catch (error) {
console.warn(`❌ 레이아웃 ${layout.layout_id} 업데이트 오류:`, error);
// 개별 레이아웃 오류는 무시하고 계속 진행
}
}
console.log(`✅ 총 ${updateCount}개 버튼의 targetScreenId 업데이트 완료`);
return updateCount;
}
} }
// 서비스 인스턴스 export // 서비스 인스턴스 export

View File

@ -640,6 +640,429 @@ class TableCategoryValueService {
children: this.buildHierarchy(values, v.valueId!), children: this.buildHierarchy(values, v.valueId!),
})); }));
} }
// ================================================
// 컬럼 매핑 관련 메서드 (논리명 ↔ 물리명)
// ================================================
/**
*
*
* @param tableName -
* @param menuObjid - OBJID
* @param companyCode -
* @returns { logical_column: physical_column }
*/
async getColumnMapping(
tableName: string,
menuObjid: number,
companyCode: string
): Promise<Record<string, string>> {
const pool = getPool();
try {
logger.info("컬럼 매핑 조회", { tableName, menuObjid, companyCode });
// 멀티테넌시 적용
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 매핑 조회 가능
query = `
SELECT
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName"
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
`;
params = [tableName, menuObjid];
} else {
// 일반 회사: 자신의 매핑만 조회
query = `
SELECT
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName"
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
params = [tableName, menuObjid, companyCode];
}
const result = await pool.query(query, params);
// { logical_column: physical_column } 형태로 변환
const mapping: Record<string, string> = {};
result.rows.forEach((row: any) => {
mapping[row.logicalColumnName] = row.physicalColumnName;
});
logger.info(`컬럼 매핑 ${Object.keys(mapping).length}개 조회 완료`, {
tableName,
menuObjid,
companyCode,
});
return mapping;
} catch (error: any) {
logger.error(`컬럼 매핑 조회 실패: ${error.message}`);
throw error;
}
}
/**
* /
*
* @param tableName -
* @param logicalColumnName -
* @param physicalColumnName -
* @param menuObjid - OBJID
* @param companyCode -
* @param userId - ID
* @param description - ()
*/
async createColumnMapping(
tableName: string,
logicalColumnName: string,
physicalColumnName: string,
menuObjid: number,
companyCode: string,
userId: string,
description?: string
): Promise<any> {
const pool = getPool();
try {
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
// 1. 물리적 컬럼이 실제로 존재하는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
const columnCheck = await pool.query(columnCheckQuery, [
tableName,
physicalColumnName,
]);
if (columnCheck.rowCount === 0) {
throw new Error(
`테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`
);
}
// 2. 매핑 저장 (UPSERT)
const insertQuery = `
INSERT INTO category_column_mapping (
table_name,
logical_column_name,
physical_column_name,
menu_objid,
company_code,
description,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
physical_column_name = EXCLUDED.physical_column_name,
description = EXCLUDED.description,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by
RETURNING *
`;
const result = await pool.query(insertQuery, [
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
description || null,
userId,
userId,
]);
logger.info("컬럼 매핑 생성 완료", {
mappingId: result.rows[0].mapping_id,
tableName,
logicalColumnName,
physicalColumnName,
});
return result.rows[0];
} catch (error: any) {
logger.error(`컬럼 매핑 생성 실패: ${error.message}`);
throw error;
}
}
/**
*
*
* @param tableName -
* @param menuObjid - OBJID
* @param companyCode -
* @returns
*/
async getLogicalColumns(
tableName: string,
menuObjid: number,
companyCode: string
): Promise<any[]> {
const pool = getPool();
try {
logger.info("논리적 컬럼 목록 조회", {
tableName,
menuObjid,
companyCode,
});
// 멀티테넌시 적용
let query: string;
let params: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 논리적 컬럼 조회
query = `
SELECT
mapping_id AS "mappingId",
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName",
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
ORDER BY logical_column_name
`;
params = [tableName, menuObjid];
} else {
// 일반 회사: 자신의 논리적 컬럼만 조회
query = `
SELECT
mapping_id AS "mappingId",
logical_column_name AS "logicalColumnName",
physical_column_name AS "physicalColumnName",
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
ORDER BY logical_column_name
`;
params = [tableName, menuObjid, companyCode];
}
const result = await pool.query(query, params);
logger.info(`논리적 컬럼 ${result.rows.length}개 조회 완료`, {
tableName,
menuObjid,
companyCode,
});
return result.rows;
} catch (error: any) {
logger.error(`논리적 컬럼 목록 조회 실패: ${error.message}`);
throw error;
}
}
/**
*
*
* @param mappingId - ID
* @param companyCode -
*/
async deleteColumnMapping(
mappingId: number,
companyCode: string
): Promise<void> {
const pool = getPool();
try {
logger.info("컬럼 매핑 삭제", { mappingId, companyCode });
// 멀티테넌시 적용
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 모든 매핑 삭제 가능
deleteQuery = `
DELETE FROM category_column_mapping
WHERE mapping_id = $1
`;
deleteParams = [mappingId];
} else {
// 일반 회사: 자신의 매핑만 삭제 가능
deleteQuery = `
DELETE FROM category_column_mapping
WHERE mapping_id = $1
AND company_code = $2
`;
deleteParams = [mappingId, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
if (result.rowCount === 0) {
throw new Error("컬럼 매핑을 찾을 수 없거나 권한이 없습니다");
}
logger.info("컬럼 매핑 삭제 완료", { mappingId, companyCode });
} catch (error: any) {
logger.error(`컬럼 매핑 삭제 실패: ${error.message}`);
throw error;
}
}
/**
*
*
*
*
* @param tableName -
* @param menuObjid - OBJID
* @param companyCode -
* @param data -
* @returns
*/
async convertToPhysicalColumns(
tableName: string,
menuObjid: number,
companyCode: string,
data: Record<string, any>
): Promise<Record<string, any>> {
try {
// 컬럼 매핑 조회
const mapping = await this.getColumnMapping(tableName, menuObjid, companyCode);
// 논리적 컬럼명 → 물리적 컬럼명 변환
const physicalData: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
physicalData[physicalColumn] = value;
}
logger.info("컬럼명 변환 완료", {
tableName,
menuObjid,
logicalColumns: Object.keys(data),
physicalColumns: Object.keys(physicalData),
});
return physicalData;
} catch (error: any) {
logger.error(`컬럼명 변환 실패: ${error.message}`);
// 매핑이 없으면 원본 데이터 그대로 반환
return data;
}
}
/**
* 2
*
*
*
* @param companyCode -
* @returns 2
*/
async getSecondLevelMenus(companyCode: string): Promise<any[]> {
const pool = getPool();
try {
logger.info("2레벨 메뉴 목록 조회", { companyCode });
// menu_info 테이블에 company_code 컬럼이 있는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'menu_info' AND column_name = 'company_code'
`;
const columnCheck = await pool.query(columnCheckQuery);
const hasCompanyCode = columnCheck.rows.length > 0;
logger.info("menu_info 테이블 company_code 컬럼 존재 여부", { hasCompanyCode });
let query: string;
let params: any[];
if (!hasCompanyCode) {
// company_code 컬럼이 없는 경우: 모든 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else if (companyCode === "*") {
// 최고 관리자: 모든 회사의 2레벨 사용자 메뉴 조회
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
ORDER BY m0.seq, m1.seq
`;
params = [];
} else {
// 일반 회사: 자신의 회사 메뉴만 조회 (공통 메뉴 제외)
query = `
SELECT
m1.objid as "menuObjid",
m1.menu_name_kor as "menuName",
m0.menu_name_kor as "parentMenuName",
m1.screen_code as "screenCode"
FROM menu_info m1
INNER JOIN menu_info m0 ON m1.parent_obj_id = m0.objid
WHERE m1.menu_type = 1
AND m1.status = 'active'
AND m0.parent_obj_id = 0
AND m1.company_code = $1
ORDER BY m0.seq, m1.seq
`;
params = [companyCode];
}
const result = await pool.query(query, params);
logger.info(`2레벨 메뉴 ${result.rows.length}개 조회 완료`, { companyCode });
return result.rows;
} catch (error: any) {
logger.error(`2레벨 메뉴 목록 조회 실패: ${error.message}`, { error });
throw error;
}
}
} }
export default new TableCategoryValueService(); export default new TableCategoryValueService();

View File

@ -128,7 +128,8 @@ export class TableManagementService {
); );
// 캐시 키 생성 (companyCode 포함) // 캐시 키 생성 (companyCode 포함)
const cacheKey = CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`; const cacheKey =
CacheKeys.TABLE_COLUMNS(tableName, page, size) + `_${companyCode}`;
const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName); const countCacheKey = CacheKeys.TABLE_COLUMN_COUNT(tableName);
// 캐시에서 먼저 확인 // 캐시에서 먼저 확인
@ -162,9 +163,9 @@ export class TableManagementService {
// 페이지네이션 적용한 컬럼 조회 // 페이지네이션 적용한 컬럼 조회
const offset = (page - 1) * size; const offset = (page - 1) * size;
// 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기 // 🔥 company_code가 있으면 table_type_columns 조인하여 회사별 inputType 가져오기
const rawColumns = companyCode const rawColumns = companyCode
? await query<any>( ? await query<any>(
`SELECT `SELECT
c.column_name as "columnName", c.column_name as "columnName",
@ -249,21 +250,89 @@ export class TableManagementService {
[tableName, size, offset] [tableName, size, offset]
); );
// 🆕 category_column_mapping 조회
const tableExistsResult = await query<any>(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'category_column_mapping'
) as table_exists`
);
const mappingTableExists = tableExistsResult[0]?.table_exists === true;
let categoryMappings: Map<string, number[]> = new Map();
if (mappingTableExists && companyCode) {
logger.info("📥 getColumnList: 카테고리 매핑 조회 시작", {
tableName,
companyCode,
});
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("✅ getColumnList: 카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("✅ getColumnList: categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
}
// BigInt를 Number로 변환하여 JSON 직렬화 문제 해결 // BigInt를 Number로 변환하여 JSON 직렬화 문제 해결
const columns: ColumnTypeInfo[] = rawColumns.map((column) => ({ const columns: ColumnTypeInfo[] = rawColumns.map((column) => {
...column, const baseColumn = {
maxLength: column.maxLength ? Number(column.maxLength) : null, ...column,
numericPrecision: column.numericPrecision maxLength: column.maxLength ? Number(column.maxLength) : null,
? Number(column.numericPrecision) numericPrecision: column.numericPrecision
: null, ? Number(column.numericPrecision)
numericScale: column.numericScale ? Number(column.numericScale) : null, : null,
displayOrder: column.displayOrder ? Number(column.displayOrder) : null, numericScale: column.numericScale
// 자동 매핑: webType이 기본값('text')인 경우 DB 타입에 따라 자동 추론 ? Number(column.numericScale)
webType: : null,
column.webType === "text" displayOrder: column.displayOrder
? this.inferWebType(column.dataType) ? Number(column.displayOrder)
: column.webType, : null,
})); // webType은 사용자가 명시적으로 설정한 값을 그대로 사용
// (자동 추론은 column_labels에 없는 경우에만 SQL 쿼리의 COALESCE에서 처리됨)
webType: column.webType,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (
column.inputType === "category" &&
categoryMappings.has(column.columnName)
) {
const menus = categoryMappings.get(column.columnName);
logger.info(
`✅ getColumnList: 컬럼 ${column.columnName}에 카테고리 메뉴 추가`,
{ menus }
);
return {
...baseColumn,
categoryMenus: menus,
};
}
return baseColumn;
});
const totalPages = Math.ceil(total / size); const totalPages = Math.ceil(total / size);
@ -360,7 +429,9 @@ export class TableManagementService {
companyCode: string // 🔥 회사 코드 추가 companyCode: string // 🔥 회사 코드 추가
): Promise<void> { ): Promise<void> {
try { try {
logger.info(`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`); logger.info(
`컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode}`
);
// 테이블이 table_labels에 없으면 자동 추가 // 테이블이 table_labels에 없으면 자동 추가
await this.insertTableIfNotExists(tableName); await this.insertTableIfNotExists(tableName);
@ -406,17 +477,22 @@ export class TableManagementService {
// detailSettings가 문자열이면 파싱, 객체면 그대로 사용 // detailSettings가 문자열이면 파싱, 객체면 그대로 사용
let parsedDetailSettings: Record<string, any> | undefined = undefined; let parsedDetailSettings: Record<string, any> | undefined = undefined;
if (settings.detailSettings) { if (settings.detailSettings) {
if (typeof settings.detailSettings === 'string') { if (typeof settings.detailSettings === "string") {
try { try {
parsedDetailSettings = JSON.parse(settings.detailSettings); parsedDetailSettings = JSON.parse(settings.detailSettings);
} catch (e) { } catch (e) {
logger.warn(`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`); logger.warn(
`detailSettings 파싱 실패, 그대로 사용: ${settings.detailSettings}`
);
} }
} else if (typeof settings.detailSettings === 'object') { } else if (typeof settings.detailSettings === "object") {
parsedDetailSettings = settings.detailSettings as Record<string, any>; parsedDetailSettings = settings.detailSettings as Record<
string,
any
>;
} }
} }
await this.updateColumnInputType( await this.updateColumnInputType(
tableName, tableName,
columnName, columnName,
@ -484,7 +560,7 @@ export class TableManagementService {
cache.deleteByPattern(`table_columns:${tableName}:`); cache.deleteByPattern(`table_columns:${tableName}:`);
cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName)); cache.delete(CacheKeys.TABLE_COLUMN_COUNT(tableName));
logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, company: ${companyCode}`); logger.info(`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}`);
} catch (error) { } catch (error) {
logger.error( logger.error(
`전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`, `전체 컬럼 설정 일괄 업데이트 중 오류 발생: ${tableName}`,
@ -1076,7 +1152,7 @@ export class TableManagementService {
if (typeof value === "object" && value !== null && "value" in value) { if (typeof value === "object" && value !== null && "value" in value) {
actualValue = value.value; actualValue = value.value;
operator = value.operator || "contains"; operator = value.operator || "contains";
logger.info("🔍 필터 객체 처리:", { logger.info("🔍 필터 객체 처리:", {
columnName, columnName,
originalValue: value, originalValue: value,
@ -1123,11 +1199,19 @@ export class TableManagementService {
switch (webType) { switch (webType) {
case "date": case "date":
case "datetime": case "datetime":
return this.buildDateRangeCondition(columnName, actualValue, paramIndex); return this.buildDateRangeCondition(
columnName,
actualValue,
paramIndex
);
case "number": case "number":
case "decimal": case "decimal":
return this.buildNumberRangeCondition(columnName, actualValue, paramIndex); return this.buildNumberRangeCondition(
columnName,
actualValue,
paramIndex
);
case "code": case "code":
return await this.buildCodeSearchCondition( return await this.buildCodeSearchCondition(
@ -1163,7 +1247,7 @@ export class TableManagementService {
if (typeof value === "object" && value !== null && "value" in value) { if (typeof value === "object" && value !== null && "value" in value) {
fallbackValue = value.value; fallbackValue = value.value;
} }
return { return {
whereClause: `${columnName}::text ILIKE $${paramIndex}`, whereClause: `${columnName}::text ILIKE $${paramIndex}`,
values: [`%${fallbackValue}%`], values: [`%${fallbackValue}%`],
@ -1526,6 +1610,7 @@ export class TableManagementService {
sortBy?: string; sortBy?: string;
sortOrder?: string; sortOrder?: string;
companyCode?: string; companyCode?: string;
dataFilter?: any; // 🆕 DataFilterConfig
} }
): Promise<{ ): Promise<{
data: any[]; data: any[];
@ -1535,7 +1620,15 @@ export class TableManagementService {
totalPages: number; totalPages: number;
}> { }> {
try { try {
const { page, size, search = {}, sortBy, sortOrder = "asc", companyCode } = options; const {
page,
size,
search = {},
sortBy,
sortOrder = "asc",
companyCode,
dataFilter,
} = options;
const offset = (page - 1) * size; const offset = (page - 1) * size;
logger.info(`테이블 데이터 조회: ${tableName}`, options); logger.info(`테이블 데이터 조회: ${tableName}`, options);
@ -1554,7 +1647,9 @@ export class TableManagementService {
whereConditions.push(`company_code = $${paramIndex}`); whereConditions.push(`company_code = $${paramIndex}`);
searchValues.push(companyCode); searchValues.push(companyCode);
paramIndex++; paramIndex++;
logger.info(`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`); logger.info(
`🔒 멀티테넌시 필터 추가 (기본 조회): company_code = ${companyCode}`
);
} }
if (search && Object.keys(search).length > 0) { if (search && Object.keys(search).length > 0) {
@ -1592,6 +1687,29 @@ export class TableManagementService {
} }
} }
// 🆕 데이터 필터 적용
if (
dataFilter &&
dataFilter.enabled &&
dataFilter.filters &&
dataFilter.filters.length > 0
) {
const {
buildDataFilterWhereClause,
} = require("../utils/dataFilterUtil");
const { whereClause: filterWhere, params: filterParams } =
buildDataFilterWhereClause(dataFilter, paramIndex);
if (filterWhere) {
whereConditions.push(filterWhere);
searchValues.push(...filterParams);
paramIndex += filterParams.length;
logger.info(`🔍 데이터 필터 적용: ${filterWhere}`);
logger.info(`🔍 필터 파라미터:`, filterParams);
}
}
const whereClause = const whereClause =
whereConditions.length > 0 whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
@ -1623,7 +1741,9 @@ export class TableManagementService {
`; `;
logger.info(`🔍 실행할 SQL: ${dataQuery}`); logger.info(`🔍 실행할 SQL: ${dataQuery}`);
logger.info(`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`); logger.info(
`🔍 파라미터: ${JSON.stringify([...searchValues, size, offset])}`
);
let data = await query<any>(dataQuery, [...searchValues, size, offset]); let data = await query<any>(dataQuery, [...searchValues, size, offset]);
@ -2095,6 +2215,7 @@ export class TableManagementService {
joinAlias: string; joinAlias: string;
}>; }>;
screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정 screenEntityConfigs?: Record<string, any>; // 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
} }
): Promise<EntityJoinResponse> { ): Promise<EntityJoinResponse> {
const startTime = Date.now(); const startTime = Date.now();
@ -2254,18 +2375,99 @@ export class TableManagementService {
const selectColumns = columns.data.map((col: any) => col.column_name); const selectColumns = columns.data.map((col: any) => col.column_name);
// WHERE 절 구성 // WHERE 절 구성
let whereClause = await this.buildWhereClause( let whereClause = await this.buildWhereClause(tableName, options.search);
tableName,
options.search
);
// 멀티테넌시 필터 추가 (company_code) // 멀티테넌시 필터 추가 (company_code)
if (options.companyCode) { if (options.companyCode) {
const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`; const companyFilter = `main.company_code = '${options.companyCode.replace(/'/g, "''")}'`;
whereClause = whereClause whereClause = whereClause
? `${whereClause} AND ${companyFilter}` ? `${whereClause} AND ${companyFilter}`
: companyFilter; : companyFilter;
logger.info(`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`); logger.info(
`🔒 멀티테넌시 필터 추가 (Entity 조인): company_code = ${options.companyCode}`
);
}
// 🆕 데이터 필터 적용 (Entity 조인) - 파라미터 바인딩 없이 직접 값 삽입
if (
options.dataFilter &&
options.dataFilter.enabled &&
options.dataFilter.filters &&
options.dataFilter.filters.length > 0
) {
const filterConditions: string[] = [];
for (const filter of options.dataFilter.filters) {
const { columnName, operator, value } = filter;
if (!columnName || value === undefined || value === null) {
continue;
}
const safeColumn = `main."${columnName}"`;
switch (operator) {
case "equals":
filterConditions.push(
`${safeColumn} = '${String(value).replace(/'/g, "''")}'`
);
break;
case "not_equals":
filterConditions.push(
`${safeColumn} != '${String(value).replace(/'/g, "''")}'`
);
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} IN (${values})`);
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const values = value
.map((v) => `'${String(v).replace(/'/g, "''")}'`)
.join(", ");
filterConditions.push(`${safeColumn} NOT IN (${values})`);
}
break;
case "contains":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}%'`
);
break;
case "starts_with":
filterConditions.push(
`${safeColumn} LIKE '${String(value).replace(/'/g, "''")}%'`
);
break;
case "ends_with":
filterConditions.push(
`${safeColumn} LIKE '%${String(value).replace(/'/g, "''")}'`
);
break;
case "is_null":
filterConditions.push(`${safeColumn} IS NULL`);
break;
case "is_not_null":
filterConditions.push(`${safeColumn} IS NOT NULL`);
break;
}
}
if (filterConditions.length > 0) {
const logicalOperator =
options.dataFilter.matchType === "any" ? " OR " : " AND ";
const filterWhere = `(${filterConditions.join(logicalOperator)})`;
whereClause = whereClause
? `${whereClause} AND ${filterWhere}`
: filterWhere;
logger.info(`🔍 데이터 필터 적용 (Entity 조인): ${filterWhere}`);
}
} }
// ORDER BY 절 구성 // ORDER BY 절 구성
@ -2347,12 +2549,19 @@ export class TableManagementService {
whereClause whereClause
); );
// ⚠️ SQL 쿼리 로깅 (디버깅용)
logger.info(`🔍 [executeJoinQuery] 실행할 SQL:\n${dataQuery}`);
// 병렬 실행 // 병렬 실행
const [dataResult, countResult] = await Promise.all([ const [dataResult, countResult] = await Promise.all([
query(dataQuery), query(dataQuery),
query(countQuery), query(countQuery),
]); ]);
logger.info(
`✅ [executeJoinQuery] 조회 완료: ${dataResult?.length}개 행`
);
const data = Array.isArray(dataResult) ? dataResult : []; const data = Array.isArray(dataResult) ? dataResult : [];
const total = const total =
Array.isArray(countResult) && countResult.length > 0 Array.isArray(countResult) && countResult.length > 0
@ -2581,11 +2790,17 @@ export class TableManagementService {
); );
} }
basicResult = await this.getTableData(tableName, { ...fallbackOptions, companyCode: options.companyCode }); basicResult = await this.getTableData(tableName, {
...fallbackOptions,
companyCode: options.companyCode,
});
} }
} else { } else {
// Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용 // Entity 조인 컬럼 검색이 없는 경우 기존 캐시 방식 사용
basicResult = await this.getTableData(tableName, { ...options, companyCode: options.companyCode }); basicResult = await this.getTableData(tableName, {
...options,
companyCode: options.companyCode,
});
} }
// Entity 값들을 캐시에서 룩업하여 변환 // Entity 값들을 캐시에서 룩업하여 변환
@ -2859,13 +3074,20 @@ export class TableManagementService {
// 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업 // 모든 조인이 캐시 가능한 경우: 기본 쿼리 + 캐시 룩업
else { else {
// whereClause에서 company_code 추출 (멀티테넌시 필터) // whereClause에서 company_code 추출 (멀티테넌시 필터)
const companyCodeMatch = whereClause.match(/main\.company_code\s*=\s*'([^']+)'/); const companyCodeMatch = whereClause.match(
/main\.company_code\s*=\s*'([^']+)'/
);
const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined; const companyCode = companyCodeMatch ? companyCodeMatch[1] : undefined;
return await this.executeCachedLookup( return await this.executeCachedLookup(
tableName, tableName,
cacheableJoins, cacheableJoins,
{ page: Math.floor(offset / limit) + 1, size: limit, search: {}, companyCode }, {
page: Math.floor(offset / limit) + 1,
size: limit,
search: {},
companyCode,
},
startTime startTime
); );
} }
@ -2887,7 +3109,7 @@ export class TableManagementService {
for (const config of joinConfigs) { for (const config of joinConfigs) {
// table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인 // table_column_category_values는 특수 조인 조건이 필요하므로 항상 DB 조인
if (config.referenceTable === 'table_column_category_values') { if (config.referenceTable === "table_column_category_values") {
dbJoins.push(config); dbJoins.push(config);
console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`); console.log(`🔗 DB 조인 (특수 조건): ${config.referenceTable}`);
continue; continue;
@ -3152,19 +3374,88 @@ export class TableManagementService {
[tableName, companyCode] [tableName, companyCode]
); );
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => ({ // category_column_mapping 테이블 존재 여부 확인
tableName: tableName, const tableExistsResult = await query<any>(
columnName: col.columnName, `SELECT EXISTS (
displayName: col.displayName, SELECT FROM information_schema.tables
dataType: col.dataType || "varchar", WHERE table_name = 'category_column_mapping'
inputType: col.inputType, ) as table_exists`
detailSettings: col.detailSettings, );
description: "", // 필수 필드 추가 const mappingTableExists = tableExistsResult[0]?.table_exists === true;
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false, // 카테고리 컬럼의 경우, 매핑된 메뉴 목록 조회
displayOrder: 0, let categoryMappings: Map<string, number[]> = new Map();
isVisible: true, if (mappingTableExists) {
})); logger.info("카테고리 매핑 조회 시작", { tableName, companyCode });
const mappings = await query<any>(
`SELECT
logical_column_name as "columnName",
menu_objid as "menuObjid"
FROM category_column_mapping
WHERE table_name = $1
AND company_code = $2`,
[tableName, companyCode]
);
logger.info("카테고리 매핑 조회 완료", {
tableName,
companyCode,
mappingCount: mappings.length,
mappings: mappings,
});
mappings.forEach((m: any) => {
if (!categoryMappings.has(m.columnName)) {
categoryMappings.set(m.columnName, []);
}
categoryMappings.get(m.columnName)!.push(Number(m.menuObjid));
});
logger.info("categoryMappings Map 생성 완료", {
size: categoryMappings.size,
entries: Array.from(categoryMappings.entries()),
});
} else {
logger.warn("category_column_mapping 테이블이 존재하지 않음");
}
const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => {
const baseInfo = {
tableName: tableName,
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType || "varchar",
inputType: col.inputType,
detailSettings: col.detailSettings,
description: "", // 필수 필드 추가
isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환
isPrimaryKey: false,
displayOrder: 0,
isVisible: true,
};
// 카테고리 타입인 경우 categoryMenus 추가
if (
col.inputType === "category" &&
categoryMappings.has(col.columnName)
) {
const menus = categoryMappings.get(col.columnName);
logger.info(`✅ 컬럼 ${col.columnName}에 카테고리 메뉴 추가`, {
menus,
});
return {
...baseInfo,
categoryMenus: menus,
};
}
if (col.inputType === "category") {
logger.warn(`⚠️ 카테고리 컬럼 ${col.columnName}에 매핑 없음`);
}
return baseInfo;
});
logger.info( logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼` `컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
@ -3183,7 +3474,10 @@ export class TableManagementService {
* 지원: 컬럼 * 지원: 컬럼
* @deprecated getColumnInputTypes * @deprecated getColumnInputTypes
*/ */
async getColumnWebTypes(tableName: string, companyCode: string): Promise<ColumnTypeInfo[]> { async getColumnWebTypes(
tableName: string,
companyCode: string
): Promise<ColumnTypeInfo[]> {
logger.warn( logger.warn(
`레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장` `레거시 메서드 사용: getColumnWebTypes → getColumnInputTypes 사용 권장`
); );

View File

@ -0,0 +1,154 @@
/**
*
* DataFilterConfig를 SQL WHERE
*/
export interface ColumnFilter {
id: string;
columnName: string;
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
value: string | string[];
valueType: "static" | "category" | "code";
}
export interface DataFilterConfig {
enabled: boolean;
filters: ColumnFilter[];
matchType: "all" | "any"; // AND / OR
}
/**
* DataFilterConfig를 SQL WHERE
* @param dataFilter
* @param tableAlias (: "r", "t1") -
* @param startParamIndex (: 1이면 $1부터 )
* @returns { whereClause: string, params: any[] }
*/
export function buildDataFilterWhereClause(
dataFilter: DataFilterConfig | undefined,
tableAlias?: string,
startParamIndex: number = 1
): { whereClause: string; params: any[] } {
if (!dataFilter || !dataFilter.enabled || !dataFilter.filters || dataFilter.filters.length === 0) {
return { whereClause: "", params: [] };
}
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = startParamIndex;
// 테이블 별칭이 있으면 "alias."를 붙이고, 없으면 그냥 컬럼명만
const getColumnRef = (colName: string) => {
return tableAlias ? `${tableAlias}."${colName}"` : `"${colName}"`;
};
for (const filter of dataFilter.filters) {
const { columnName, operator, value } = filter;
if (!columnName) {
continue; // 컬럼명이 없으면 스킵
}
const columnRef = getColumnRef(columnName);
switch (operator) {
case "equals":
conditions.push(`${columnRef} = $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "not_equals":
conditions.push(`${columnRef} != $${paramIndex}`);
params.push(value);
paramIndex++;
break;
case "in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
}
break;
case "not_in":
if (Array.isArray(value) && value.length > 0) {
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
conditions.push(`${columnRef} NOT IN (${placeholders})`);
params.push(...value);
paramIndex += value.length;
}
break;
case "contains":
conditions.push(`${columnRef} LIKE $${paramIndex}`);
params.push(`%${value}%`);
paramIndex++;
break;
case "starts_with":
conditions.push(`${columnRef} LIKE $${paramIndex}`);
params.push(`${value}%`);
paramIndex++;
break;
case "ends_with":
conditions.push(`${columnRef} LIKE $${paramIndex}`);
params.push(`%${value}`);
paramIndex++;
break;
case "is_null":
conditions.push(`${columnRef} IS NULL`);
break;
case "is_not_null":
conditions.push(`${columnRef} IS NOT NULL`);
break;
default:
// 알 수 없는 연산자는 무시
break;
}
}
if (conditions.length === 0) {
return { whereClause: "", params: [] };
}
// matchType에 따라 AND / OR 조합
const logicalOperator = dataFilter.matchType === "any" ? " OR " : " AND ";
const whereClause = `(${conditions.join(logicalOperator)})`;
return { whereClause, params };
}
/**
* WHERE dataFilter
* @param existingWhere WHERE (: "company_code = $1")
* @param existingParams
* @param dataFilter
* @returns { whereClause: string, params: any[] }
*/
export function appendDataFilterToWhere(
existingWhere: string,
existingParams: any[],
dataFilter: DataFilterConfig | undefined
): { whereClause: string; params: any[] } {
const { whereClause: filterWhere, params: filterParams } = buildDataFilterWhereClause(
dataFilter,
existingParams.length + 1
);
if (!filterWhere) {
return { whereClause: existingWhere, params: existingParams };
}
const newWhere = existingWhere ? `${existingWhere} AND ${filterWhere}` : filterWhere;
const newParams = [...existingParams, ...filterParams];
return { whereClause: newWhere, params: newParams };
}

View File

@ -0,0 +1,634 @@
# 카테고리 메뉴별 컬럼 분리 구현 완료 보고서
## 📋 개요
**문제**: 같은 테이블의 같은 컬럼을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우 지원 불가
**해결**: 가상 컬럼 분리 (Virtual Column Mapping) 방식 구현
**구현 날짜**: 2025-11-13
---
## ✅ 구현 완료 항목
### 1. 데이터베이스 스키마
#### `category_column_mapping` 테이블 생성 ✅
**파일**: `db/migrations/054_create_category_column_mapping.sql`
```sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL, -- 논리적 컬럼명
physical_column_name VARCHAR(100) NOT NULL, -- 물리적 컬럼명
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
```
**인덱스**:
- `idx_mapping_table_menu`: 조회 성능 최적화
- `idx_mapping_company`: 멀티테넌시 필터링
### 2. 백엔드 API 구현
#### 컨트롤러 (tableCategoryValueController.ts) ✅
구현된 API 엔드포인트:
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/table-categories/column-mapping/:tableName/:menuObjid` | 컬럼 매핑 조회 |
| POST | `/table-categories/column-mapping` | 컬럼 매핑 생성/수정 |
| GET | `/table-categories/logical-columns/:tableName/:menuObjid` | 논리적 컬럼 목록 조회 |
| DELETE | `/table-categories/column-mapping/:mappingId` | 컬럼 매핑 삭제 |
**멀티테넌시 지원**:
- ✅ 최고 관리자(`company_code = "*"`): 모든 매핑 조회/수정 가능
- ✅ 일반 회사: 자신의 매핑만 조회/수정 가능
#### 서비스 (tableCategoryValueService.ts) ✅
구현된 주요 메서드:
1. `getColumnMapping()`: 논리명 → 물리명 매핑 조회
2. `createColumnMapping()`: 컬럼 매핑 생성 (UPSERT)
3. `getLogicalColumns()`: 논리적 컬럼 목록 조회
4. `deleteColumnMapping()`: 컬럼 매핑 삭제
5. `convertToPhysicalColumns()`: 데이터 저장 시 자동 변환
**물리적 컬럼 존재 검증**:
```typescript
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
```
**UPSERT 지원**:
```sql
INSERT INTO category_column_mapping (...)
VALUES (...)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET ...
```
### 3. 프론트엔드 API 클라이언트
#### `frontend/lib/api/tableCategoryValue.ts`
구현된 함수:
```typescript
// 컬럼 매핑 조회
getColumnMapping(tableName: string, menuObjid: number)
// 논리적 컬럼 목록 조회
getLogicalColumns(tableName: string, menuObjid: number)
// 컬럼 매핑 생성
createColumnMapping(data: {
tableName: string;
logicalColumnName: string;
physicalColumnName: string;
menuObjid: number;
description?: string;
})
// 컬럼 매핑 삭제
deleteColumnMapping(mappingId: number)
```
**에러 처리**:
- 네트워크 오류 시 `{ success: false, error: message }` 반환
- 콘솔 로그로 디버깅 정보 출력
### 4. 프론트엔드 UI 컴포넌트
#### `AddCategoryColumnDialog.tsx`
**기능**:
- 논리적 컬럼명 입력
- 물리적 컬럼 선택 (드롭다운)
- 설명 입력 (선택사항)
- 적용 메뉴 표시 (읽기 전용)
**검증 로직**:
- 논리적 컬럼명 필수 체크
- 물리적 컬럼 선택 필수 체크
- 중복 매핑 방지
**shadcn/ui 스타일 가이드 준수**:
- ✅ 반응형 크기: `max-w-[95vw] sm:max-w-[500px]`
- ✅ 텍스트 크기: `text-xs sm:text-sm`
- ✅ 입력 필드: `h-8 sm:h-10`
- ✅ 버튼 레이아웃: `flex-1` (모바일), `flex-none` (데스크톱)
---
## 🔄 작동 방식
### 예시: item_info.status 컬럼 분리
#### 1단계: 컬럼 매핑 생성
```
기준정보 > 품목정보 (menu_objid=103)
논리적 컬럼: status_stock
물리적 컬럼: status
카테고리: "정상", "대기", "품절"
영업관리 > 판매품목정보 (menu_objid=203)
논리적 컬럼: status_sales
물리적 컬럼: status
카테고리: "판매중", "판매중지", "품절"
```
#### 2단계: 카테고리 값 저장
```sql
-- table_column_category_values 테이블
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_stock', 'NORMAL', '정상', 103),
('item_info', 'status_sales', 'ON_SALE', '판매중', 203);
```
#### 3단계: 데이터 입력 (자동 변환)
**사용자 입력 (논리적 컬럼명)**:
```typescript
{
item_name: "키보드",
status_stock: "정상" // 논리적 컬럼명
}
```
**백엔드에서 자동 변환 (물리적 컬럼명)**:
```typescript
// convertToPhysicalColumns() 호출
{
item_name: "키보드",
status: "정상" // 물리적 컬럼명
}
```
**DB에 저장**:
```sql
INSERT INTO item_info (item_name, status, company_code)
VALUES ('키보드', '정상', 'COMPANY_A');
```
#### 4단계: 데이터 조회 (자동 매핑)
**DB 쿼리 결과**:
```typescript
{
item_name: "키보드",
status: "정상" // 물리적 컬럼명
}
```
**프론트엔드 표시 (논리적 컬럼명으로 자동 매핑)**:
```typescript
// 기준정보 > 품목정보에서 보기
{
item_name: "키보드",
status_stock: "정상" // 논리적 컬럼명
}
// 영업관리 > 판매품목정보에서 보기
{
item_name: "마우스",
status_sales: "판매중" // 다른 논리적 컬럼명
}
```
---
## 📊 데이터 흐름도
```
┌────────────────────────────────────────────────────┐
│ 프론트엔드 (UI) │
├────────────────────────────────────────────────────┤
│ 기준정보 > 품목정보 │
│ - status_stock: "정상", "대기", "품절" │
│ │
│ 영업관리 > 판매품목정보 │
│ - status_sales: "판매중", "판매중지", "품절" │
└─────────────────┬──────────────────────────────────┘
│ (논리적 컬럼명 사용)
┌────────────────────────────────────────────────────┐
│ category_column_mapping (매핑 테이블) │
├────────────────────────────────────────────────────┤
│ status_stock → status (menu_objid=103) │
│ status_sales → status (menu_objid=203) │
└─────────────────┬──────────────────────────────────┘
│ (자동 변환)
┌────────────────────────────────────────────────────┐
│ item_info 테이블 (실제 DB) │
├────────────────────────────────────────────────────┤
│ item_name │ status (물리적 컬럼 - 하나만 존재) │
│ 키보드 │ 정상 │
│ 마우스 │ 판매중 │
└────────────────────────────────────────────────────┘
```
---
## 🎯 구현 효과
### 1. 문제 해결 ✅
**Before (문제)**:
```
기준정보 > 품목정보: status = "정상", "대기", "품절"
영업관리 > 판매품목정보: status = "판매중", "판매중지", "품절"
→ 같은 컬럼이라 불가능!
```
**After (해결)**:
```
기준정보 > 품목정보: status_stock = "정상", "대기", "품절"
영업관리 > 판매품목정보: status_sales = "판매중", "판매중지", "품절"
→ 논리적으로 분리되어 가능!
```
### 2. 사용자 경험 개선
- ✅ 메뉴별 맞춤형 카테고리 관리
- ✅ 직관적인 논리적 컬럼명 사용
- ✅ 관리자가 UI에서 쉽게 설정 가능
- ✅ 불필요한 카테고리가 표시되지 않음
### 3. 시스템 안정성
- ✅ 데이터베이스 스키마 변경 최소화
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 물리적 컬럼 존재 검증으로 오류 방지
- ✅ 멀티테넌시 완벽 지원
### 4. 확장성
- ✅ 새로운 메뉴 추가 시 독립적인 카테고리 설정 가능
- ✅ 다른 컴포넌트에도 유사한 패턴 적용 가능
- ✅ 메뉴별 카테고리 통계 및 분석 가능
---
## 🚀 사용 방법
### 관리자 작업 흐름
#### 1. 테이블 타입 관리 접속
```
메뉴: 시스템 관리 > 테이블 타입 관리
```
#### 2. 카테고리 컬럼 추가
```
1. 테이블 선택: item_info
2. "카테고리 컬럼 추가" 버튼 클릭
3. 실제 컬럼 선택: status
4. 논리적 컬럼명 입력: status_stock
5. 설명 입력: "재고 관리용 상태"
6. "추가" 버튼 클릭
```
#### 3. 카테고리 값 추가
```
1. 논리적 컬럼 선택: status_stock
2. "카테고리 값 추가" 버튼 클릭
3. 라벨 입력: "정상", "대기", "품절"
4. 각각 추가
```
#### 4. 다른 메뉴에 대해 반복
```
1. 영업관리 > 판매품목정보 선택
2. 논리적 컬럼명: status_sales
3. 카테고리 값: "판매중", "판매중지", "품절"
```
### 사용자 화면에서 확인
```
기준정보 > 품목정보
→ status_stock 필드가 표시됨
→ 드롭다운: "정상", "대기", "품절"
영업관리 > 판매품목정보
→ status_sales 필드가 표시됨
→ 드롭다운: "판매중", "판매중지", "품절"
```
---
## 🔧 실행 방법
### 1. 데이터베이스 마이그레이션
```sql
-- pgAdmin 또는 psql에서 실행
\i db/migrations/054_create_category_column_mapping.sql
```
**결과 확인**:
```sql
-- 테이블 생성 확인
SELECT * FROM category_column_mapping LIMIT 5;
-- 인덱스 확인
SELECT indexname FROM pg_indexes
WHERE tablename = 'category_column_mapping';
```
### 2. 백엔드 재시작 (불필요)
프로젝트 규칙에 따라 **백엔드 재시작 금지**
- 타입스크립트 파일 변경만으로 자동 반영됨
- 라우트 등록 완료됨
### 3. 프론트엔드 확인
```bash
# 프론트엔드만 재시작 (필요 시)
cd frontend
npm run dev
```
---
## 🧪 테스트 시나리오
### 시나리오 1: 기본 매핑 생성
1. **테이블 타입 관리 접속**
2. **item_info 테이블 선택**
3. **"카테고리 컬럼 추가" 클릭**
4. **입력**:
- 실제 컬럼: `status`
- 논리적 컬럼명: `status_stock`
- 설명: "재고 관리용 상태"
5. **"추가" 클릭**
6. **확인**: 매핑이 생성되었는지 확인
**예상 결과**:
- ✅ 성공 토스트 메시지 표시
- ✅ 논리적 컬럼 목록에 `status_stock` 추가됨
- ✅ DB에 매핑 레코드 생성
### 시나리오 2: 카테고리 값 추가
1. **논리적 컬럼 `status_stock` 선택**
2. **"카테고리 값 추가" 클릭**
3. **입력**:
- 라벨: `정상`
- 코드: 자동 생성
4. **"추가" 클릭**
5. **반복**: "대기", "품절" 추가
**예상 결과**:
- ✅ 각 카테고리 값이 `status_stock` 컬럼에 연결됨
- ✅ `menu_objid`가 올바르게 설정됨
### 시나리오 3: 다른 메뉴에 다른 매핑
1. **영업관리 > 판매품목정보 메뉴 선택**
2. **item_info 테이블 선택**
3. **"카테고리 컬럼 추가" 클릭**
4. **입력**:
- 실제 컬럼: `status` (동일한 물리적 컬럼)
- 논리적 컬럼명: `status_sales` (다른 논리명)
- 설명: "판매 관리용 상태"
5. **카테고리 값 추가**: "판매중", "판매중지", "품절"
**예상 결과**:
- ✅ 기준정보 > 품목정보: `status_stock` 표시
- ✅ 영업관리 > 판매품목정보: `status_sales` 표시
- ✅ 서로 다른 카테고리 값 리스트
### 시나리오 4: 데이터 저장 및 조회
1. **기준정보 > 품목정보에서 데이터 입력**
- 품목명: "키보드"
- status_stock: "정상"
2. **저장**
3. **DB 확인**:
```sql
SELECT item_name, status FROM item_info WHERE item_name = '키보드';
-- 결과: status = '정상' (물리적 컬럼)
```
4. **영업관리 > 판매품목정보에서 조회**
- status_sales 필드로 표시되지 않음 (다른 논리명)
**예상 결과**:
- ✅ 논리적 컬럼명으로 입력
- ✅ 물리적 컬럼명으로 저장
- ✅ 메뉴별 독립적인 카테고리 표시
---
## 📝 주의사항
### 1. 기존 데이터 호환성
**기존에 물리적 컬럼명을 직접 사용하던 경우**:
- 마이그레이션 스크립트가 자동으로 기본 매핑 생성
- `logical_column_name = physical_column_name`으로 설정
- 기존 기능 유지됨
### 2. 성능 고려사항
**컬럼 매핑 조회**:
- 인덱스 활용으로 빠른 조회
- 첫 조회 후 캐싱 권장 (향후 개선)
**데이터 저장 시 변환**:
- 매번 매핑 조회 발생
- 트랜잭션 내에서 처리하여 성능 영향 최소화
### 3. 에러 처리
**물리적 컬럼 없음**:
```
에러 메시지: "테이블 item_info에 컬럼 status2가 존재하지 않습니다"
해결: 올바른 컬럼명 선택
```
**논리적 컬럼명 중복**:
```
에러 메시지: "중복된 키 값이 고유 제약조건을 위반합니다"
해결: 다른 논리적 컬럼명 사용
```
---
## 🔍 디버깅 가이드
### 백엔드 로그 확인
```bash
# 로그 파일 위치
tail -f backend-node/logs/app.log
# 컬럼 매핑 조회 로그
"컬럼 매핑 조회" { tableName, menuObjid, companyCode }
# 컬럼 매핑 생성 로그
"컬럼 매핑 생성 완료" { mappingId, tableName, logicalColumnName }
```
### 프론트엔드 콘솔 확인
```javascript
// 브라우저 개발자 도구 > 콘솔
"논리적 컬럼 목록 조회 시작: item_info, 103"
"컬럼 매핑 조회 완료: { status_stock: 'status' }"
```
### 데이터베이스 쿼리
```sql
-- 모든 매핑 확인
SELECT * FROM category_column_mapping
WHERE table_name = 'item_info'
ORDER BY menu_objid, logical_column_name;
-- 특정 메뉴의 매핑
SELECT
logical_column_name,
physical_column_name,
description
FROM category_column_mapping
WHERE table_name = 'item_info'
AND menu_objid = 103;
-- 카테고리 값과 매핑 조인
SELECT
ccm.logical_column_name,
ccm.physical_column_name,
tccv.value_label
FROM category_column_mapping ccm
JOIN table_column_category_values tccv
ON ccm.table_name = tccv.table_name
AND ccm.logical_column_name = tccv.column_name
AND ccm.menu_objid = tccv.menu_objid
WHERE ccm.table_name = 'item_info'
AND ccm.menu_objid = 103;
```
---
## 🎓 추가 참고 자료
### 관련 문서
- [카테고리 메뉴스코프 개선 계획서](카테고리_메뉴스코프_개선_계획서.md)
- [카테고리 메뉴별 컬럼 분리 전략](카테고리_메뉴별_컬럼_분리_전략.md)
### 주요 파일 위치
- 마이그레이션: `db/migrations/054_create_category_column_mapping.sql`
- 컨트롤러: `backend-node/src/controllers/tableCategoryValueController.ts`
- 서비스: `backend-node/src/services/tableCategoryValueService.ts`
- 라우트: `backend-node/src/routes/tableCategoryValueRoutes.ts`
- API 클라이언트: `frontend/lib/api/tableCategoryValue.ts`
- UI 컴포넌트: `frontend/components/table-category/AddCategoryColumnDialog.tsx`
---
## ✅ 체크리스트
### 개발 완료
- [x] `category_column_mapping` 테이블 생성
- [x] 백엔드: 컬럼 매핑 조회 API
- [x] 백엔드: 컬럼 매핑 생성 API
- [x] 백엔드: 논리적 컬럼 목록 조회 API
- [x] 백엔드: 컬럼 매핑 삭제 API
- [x] 백엔드: 데이터 저장 시 자동 변환 로직
- [x] 프론트엔드: API 클라이언트 함수
- [x] 프론트엔드: AddCategoryColumnDialog 컴포넌트
### 테스트 필요 (향후)
- [ ] 시나리오 1: 기본 매핑 생성
- [ ] 시나리오 2: 카테고리 값 추가
- [ ] 시나리오 3: 다른 메뉴에 다른 매핑
- [ ] 시나리오 4: 데이터 저장 및 조회
- [ ] 브라우저 테스트 (Chrome, Safari, Edge)
- [ ] 모바일 반응형 테스트
---
## 🚧 향후 개선 사항
### Phase 2 (권장)
1. **캐싱 메커니즘**
- 컬럼 매핑을 메모리에 캐싱
- 변경 시에만 재조회
- 성능 개선
2. **UI 개선**
- CategoryValueAddDialog에 논리적 컬럼 선택 기능 추가
- 매핑 관리 전용 UI 페이지
- 벌크 매핑 생성 기능
3. **관리 기능**
- 매핑 사용 현황 통계
- 미사용 매핑 자동 정리
- 매핑 복제 기능 (다른 메뉴로)
### Phase 3 (선택)
4. **고급 기능**
- 매핑 버전 관리
- 매핑 변경 이력 추적
- 매핑 검증 도구
---
## 📞 문의 및 지원
**문제 발생 시**:
1. 로그 파일 확인 (backend-node/logs/app.log)
2. 브라우저 콘솔 확인 (개발자 도구)
3. 데이터베이스 쿼리로 직접 확인
**추가 개발 요청**:
- 새로운 기능 제안
- 버그 리포트
- 성능 개선 제안
---
## 🎉 결론
**가상 컬럼 분리 (Virtual Column Mapping) 방식**을 성공적으로 구현하여, 같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용할 수 있게 되었습니다.
**핵심 장점**:
- ✅ 데이터베이스 스키마 변경 최소화
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
- ✅ 자동 변환으로 개발자 부담 감소
- ✅ 멀티테넌시 완벽 지원
**실무 적용**:
- 테이블 타입 관리에서 바로 사용 가능
- 기존 기능과 완전히 호환
- 확장성 있는 아키텍처
이 시스템을 통해 사용자는 메뉴별로 맞춤형 카테고리를 쉽게 관리할 수 있으며, 관리자는 유연하게 카테고리를 설정할 수 있습니다.

View File

@ -0,0 +1,905 @@
# 카테고리 메뉴별 컬럼 분리 전략
## 1. 문제 정의
### 상황
같은 테이블(`item_info`)의 같은 컬럼(`status`)을 서로 다른 메뉴에서 다른 카테고리 값으로 사용하고 싶은 경우
**예시**:
```
기준정보 > 품목정보 (menu_objid=103)
- status 컬럼: "정상", "대기", "품절"
영업관리 > 판매품목정보 (menu_objid=203)
- status 컬럼: "판매중", "판매중지", "품절"
```
### 현재 문제점
- `table_column_category_values` 테이블 구조:
- `table_name` + `column_name` + `menu_objid` 조합으로 카테고리 값 저장
- 같은 테이블, 같은 컬럼, 다른 메뉴 = 서로 다른 카테고리 값 사용 가능
- **하지만 실제 DB 컬럼은 하나뿐!**
---
## 2. 해결 방안 비교
### 방안 A: 가상 컬럼 분리 (Virtual Column Mapping) ⭐ **추천**
**개념**: 물리적으로는 같은 `status` 컬럼이지만, 메뉴별로 **논리적으로 다른 컬럼명**을 사용
#### 장점
- ✅ 데이터베이스 스키마 변경 불필요
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 메뉴별 완전히 독립적인 카테고리 관리
- ✅ 유연한 확장 가능
#### 단점
- ⚠️ 컬럼 매핑 관리 필요 (논리명 → 물리명)
- ⚠️ UI에서 가상 컬럼 개념 이해 필요
#### 구현 방식
**데이터베이스**:
```sql
-- table_column_category_values 테이블 사용
-- column_name을 "논리적 컬럼명"으로 저장
-- 기준정보 > 품목정보
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_stock', 'NORMAL', '정상', 103),
('item_info', 'status_stock', 'PENDING', '대기', 103),
('item_info', 'status_stock', 'OUT_OF_STOCK', '품절', 103);
-- 영업관리 > 판매품목정보
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid)
VALUES
('item_info', 'status_sales', 'ON_SALE', '판매중', 203),
('item_info', 'status_sales', 'DISCONTINUED', '판매중지', 203),
('item_info', 'status_sales', 'OUT_OF_STOCK', '품절', 203);
```
**컬럼 매핑 테이블** (새로 생성):
```sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL, -- status_stock, status_sales
physical_column_name VARCHAR(100) NOT NULL, -- status (실제 DB 컬럼)
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
-- 예시 데이터
INSERT INTO category_column_mapping
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
VALUES
('item_info', 'status_stock', 'status', 103, 'COMPANY_A'),
('item_info', 'status_sales', 'status', 203, 'COMPANY_A');
```
**프론트엔드 UI**:
```typescript
// 테이블 타입 관리에서 카테고리 컬럼 추가 시
function AddCategoryColumn({ tableName, menuObjid }: Props) {
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
return (
<Dialog>
<DialogContent>
<DialogHeader>
<DialogTitle>카테고리 컬럼 추가</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 실제 DB 컬럼 선택 */}
<div>
<Label>실제 컬럼 (물리적)</Label>
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="status">status</SelectItem>
<SelectItem value="category_type">category_type</SelectItem>
</SelectContent>
</Select>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label>논리적 컬럼명 (메뉴별 식별용)</Label>
<Input
value={logicalColumnName}
onChange={(e) => setLogicalColumnName(e.target.value)}
placeholder="예: status_stock, status_sales"
/>
<p className="text-xs text-muted-foreground mt-1">
이 메뉴에서만 사용할 고유한 이름을 입력하세요
</p>
</div>
{/* 적용할 메뉴 표시 (읽기 전용) */}
<div>
<Label>적용 메뉴</Label>
<Input value={currentMenuName} disabled />
</div>
</div>
<DialogFooter>
<Button onClick={handleSave}>저장</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
**데이터 저장 시 매핑 적용**:
```typescript
// InteractiveScreenViewer.tsx
async function saveData(formData: any) {
const companyCode = user.companyCode;
const menuObjid = screenConfig.menuObjid;
// 논리적 컬럼명 → 물리적 컬럼명 매핑
const mappingResponse = await apiClient.get(
`/api/categories/column-mapping/${tableName}/${menuObjid}`
);
const columnMapping = mappingResponse.data.data; // { status_sales: "status" }
// formData를 물리적 컬럼명으로 변환
const physicalData = {};
for (const [logicalCol, value] of Object.entries(formData)) {
const physicalCol = columnMapping[logicalCol] || logicalCol;
physicalData[physicalCol] = value;
}
// 실제 DB 저장
await apiClient.post(`/api/data/${tableName}`, physicalData);
}
```
---
### 방안 B: 물리적 컬럼 분리 (Physical Column Separation)
**개념**: 실제로 테이블에 `status_stock`, `status_sales` 같은 별도 컬럼 생성
#### 장점
- ✅ 단순하고 직관적
- ✅ 매핑 로직 불필요
#### 단점
- ❌ 데이터베이스 스키마 변경 필요
- ❌ 기존 데이터 마이그레이션 필요
- ❌ 컬럼 추가마다 DDL 실행 필요
- ❌ 유연성 부족
#### 구현 방식
**데이터베이스 스키마 변경**:
```sql
-- item_info 테이블에 컬럼 추가
ALTER TABLE item_info
ADD COLUMN status_stock VARCHAR(50),
ADD COLUMN status_sales VARCHAR(50);
-- 기존 데이터 마이그레이션
UPDATE item_info
SET
status_stock = status, -- 기본값으로 복사
status_sales = status;
```
**단점이 명확함**:
- 메뉴가 추가될 때마다 컬럼 추가 필요
- 테이블 구조가 복잡해짐
- 유지보수 어려움
---
### 방안 C: 현재 구조 유지 (Same Column, Different Values)
**개념**: 같은 `status` 컬럼을 사용하되, 메뉴별로 다른 카테고리 값만 표시
#### 장점
- ✅ 가장 단순한 구조
- ✅ 추가 개발 불필요
#### 단점
- ❌ **데이터 정합성 문제**: 실제 DB에는 하나의 값만 저장 가능
- ❌ 메뉴별로 다른 값을 저장할 수 없음
#### 예시 (문제 발생)
```
item_info 테이블의 실제 데이터:
item_id | status
--------|--------
1 | "NORMAL" (기준정보에서 입력)
2 | "ON_SALE" (영업관리에서 입력)
→ 기준정보에서 item_id=2를 볼 때 "ON_SALE"이 뭔지 모름 (정의되지 않은 값)
```
**결론**: 이 방안은 **불가능**합니다.
---
## 3. 최종 추천 방안
### 🏆 방안 A: 가상 컬럼 분리 (Virtual Column Mapping)
**이유**:
1. 데이터베이스 스키마 변경 없음
2. 메뉴별 완전히 독립적인 카테고리 관리
3. 실제 데이터 저장 시 물리적 컬럼으로 자동 매핑
4. 확장성과 유연성 확보
**핵심 개념**:
- **논리적 컬럼명**: UI와 카테고리 설정에서 사용 (`status_stock`, `status_sales`)
- **물리적 컬럼명**: 실제 DB 저장 시 사용 (`status`)
- **매핑 테이블**: 논리명과 물리명을 연결
---
## 4. 구현 계획
### Phase 1: 데이터베이스 스키마 추가
#### 4.1 컬럼 매핑 테이블 생성
```sql
-- db/migrations/054_create_category_column_mapping.sql
CREATE TABLE category_column_mapping (
mapping_id SERIAL PRIMARY KEY,
table_name VARCHAR(100) NOT NULL,
logical_column_name VARCHAR(100) NOT NULL COMMENT '논리적 컬럼명 (UI에서 사용)',
physical_column_name VARCHAR(100) NOT NULL COMMENT '물리적 컬럼명 (실제 DB 컬럼)',
menu_objid NUMERIC NOT NULL,
company_code VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_by VARCHAR(50),
updated_by VARCHAR(50),
CONSTRAINT fk_mapping_company FOREIGN KEY (company_code)
REFERENCES company_info(company_code),
CONSTRAINT fk_mapping_menu FOREIGN KEY (menu_objid)
REFERENCES menu_info(objid),
CONSTRAINT uk_mapping UNIQUE(table_name, logical_column_name, menu_objid, company_code)
);
CREATE INDEX idx_mapping_table_menu ON category_column_mapping(table_name, menu_objid);
CREATE INDEX idx_mapping_company ON category_column_mapping(company_code);
COMMENT ON TABLE category_column_mapping IS '카테고리 컬럼의 논리명-물리명 매핑';
COMMENT ON COLUMN category_column_mapping.logical_column_name IS '메뉴별 카테고리 컬럼의 논리적 이름 (예: status_stock)';
COMMENT ON COLUMN category_column_mapping.physical_column_name IS '실제 테이블의 물리적 컬럼 이름 (예: status)';
```
#### 4.2 기존 카테고리 컬럼 마이그레이션 (선택사항)
```sql
-- 기존에 직접 물리적 컬럼명을 사용하던 경우 매핑 생성
INSERT INTO category_column_mapping
(table_name, logical_column_name, physical_column_name, menu_objid, company_code)
SELECT DISTINCT
table_name,
column_name, -- 기존에는 논리명=물리명
column_name,
menu_objid,
company_code
FROM table_column_category_values
WHERE menu_objid IS NOT NULL
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code) DO NOTHING;
```
### Phase 2: 백엔드 API 구현
#### 2.1 컬럼 매핑 API
**파일**: `backend-node/src/controllers/categoryController.ts`
```typescript
/**
* 메뉴별 컬럼 매핑 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
* @returns { logical_column: physical_column } 매핑
*/
export async function getColumnMapping(req: Request, res: Response) {
const { tableName, menuObjid } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
logical_column_name,
physical_column_name,
description
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
const result = await pool.query(query, [tableName, menuObjid, companyCode]);
// { status_stock: "status", status_sales: "status" } 형태로 변환
const mapping: Record<string, string> = {};
result.rows.forEach((row) => {
mapping[row.logical_column_name] = row.physical_column_name;
});
logger.info("컬럼 매핑 조회", {
tableName,
menuObjid,
companyCode,
mappingCount: Object.keys(mapping).length,
});
return res.json({
success: true,
data: mapping,
});
}
/**
* 컬럼 매핑 생성
*/
export async function createColumnMapping(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
} = req.body;
// 입력 검증
if (!tableName || !logicalColumnName || !physicalColumnName || !menuObjid) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
}
// 물리적 컬럼이 실제로 존재하는지 확인
const columnCheckQuery = `
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
AND column_name = $2
`;
const columnCheck = await pool.query(columnCheckQuery, [tableName, physicalColumnName]);
if (columnCheck.rowCount === 0) {
return res.status(400).json({
success: false,
message: `테이블 ${tableName}에 컬럼 ${physicalColumnName}이(가) 존재하지 않습니다`,
});
}
// 매핑 저장
const query = `
INSERT INTO category_column_mapping (
table_name,
logical_column_name,
physical_column_name,
menu_objid,
company_code,
description,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (table_name, logical_column_name, menu_objid, company_code)
DO UPDATE SET
physical_column_name = EXCLUDED.physical_column_name,
description = EXCLUDED.description,
updated_at = NOW(),
updated_by = EXCLUDED.updated_by
RETURNING *
`;
const result = await pool.query(query, [
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
description || null,
req.user!.userId,
req.user!.userId,
]);
logger.info("컬럼 매핑 생성", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
companyCode,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
**라우트 등록**:
```typescript
router.get("/api/categories/column-mapping/:tableName/:menuObjid", authenticate, getColumnMapping);
router.post("/api/categories/column-mapping", authenticate, createColumnMapping);
```
#### 2.2 데이터 저장 시 매핑 적용
**파일**: `backend-node/src/controllers/dataController.ts`
```typescript
/**
* 데이터 저장 시 논리적 컬럼명 → 물리적 컬럼명 변환
*/
export async function saveData(req: Request, res: Response) {
const { tableName } = req.params;
const { menuObjid, data } = req.body;
const companyCode = req.user!.companyCode;
// 1. 컬럼 매핑 조회
const mappingQuery = `
SELECT logical_column_name, physical_column_name
FROM category_column_mapping
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
`;
const mappingResult = await pool.query(mappingQuery, [tableName, menuObjid, companyCode]);
const mapping: Record<string, string> = {};
mappingResult.rows.forEach((row) => {
mapping[row.logical_column_name] = row.physical_column_name;
});
// 2. 논리적 컬럼명 → 물리적 컬럼명 변환
const physicalData: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
const physicalColumn = mapping[key] || key; // 매핑 없으면 원래 이름 사용
physicalData[physicalColumn] = value;
}
// 3. 실제 데이터 저장
const columns = Object.keys(physicalData);
const values = Object.values(physicalData);
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
const insertQuery = `
INSERT INTO ${tableName} (${columns.join(", ")}, company_code)
VALUES (${placeholders}, $${columns.length + 1})
RETURNING *
`;
const result = await pool.query(insertQuery, [...values, companyCode]);
logger.info("데이터 저장 (컬럼 매핑 적용)", {
tableName,
menuObjid,
logicalColumns: Object.keys(data),
physicalColumns: columns,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
### Phase 3: 프론트엔드 UI 구현
#### 3.1 테이블 타입 관리: 논리적 컬럼 추가
**파일**: `frontend/components/admin/table-type-management/AddCategoryColumnDialog.tsx`
```typescript
interface AddCategoryColumnDialogProps {
tableName: string;
menuObjid: number;
menuName: string;
onSuccess: () => void;
}
export function AddCategoryColumnDialog({
tableName,
menuObjid,
menuName,
onSuccess,
}: AddCategoryColumnDialogProps) {
const [open, setOpen] = useState(false);
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
const [description, setDescription] = useState("");
// 테이블의 실제 컬럼 목록 조회
useEffect(() => {
if (open) {
async function loadColumns() {
const response = await apiClient.get(`/api/tables/${tableName}/columns`);
if (response.data.success) {
setPhysicalColumns(response.data.data.map((col: any) => col.column_name));
}
}
loadColumns();
}
}, [open, tableName]);
const handleSave = async () => {
// 1. 컬럼 매핑 생성
const mappingResponse = await apiClient.post("/api/categories/column-mapping", {
tableName,
logicalColumnName,
physicalColumnName,
menuObjid,
description,
});
if (!mappingResponse.data.success) {
toast.error("컬럼 매핑 생성 실패");
return;
}
toast.success("논리적 컬럼이 추가되었습니다");
setOpen(false);
onSuccess();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
카테고리 컬럼 추가
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
카테고리 컬럼 추가
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
같은 물리적 컬럼을 여러 메뉴에서 다른 카테고리로 사용할 수 있습니다
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 적용 메뉴 (읽기 전용) */}
<div>
<Label className="text-xs sm:text-sm">적용 메뉴</Label>
<Input
value={menuName}
disabled
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
{/* 실제 컬럼 선택 */}
<div>
<Label className="text-xs sm:text-sm">
실제 컬럼 (물리적) *
</Label>
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{physicalColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
테이블의 실제 컬럼명
</p>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label className="text-xs sm:text-sm">
논리적 컬럼명 (메뉴별 식별용) *
</Label>
<Input
value={logicalColumnName}
onChange={(e) => setLogicalColumnName(e.target.value)}
placeholder="예: status_stock, status_sales"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
이 메뉴에서만 사용할 고유한 이름을 입력하세요
</p>
</div>
{/* 설명 (선택사항) */}
<div>
<Label className="text-xs sm:text-sm">설명</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
className="text-xs sm:text-sm"
rows={2}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
취소
</Button>
<Button
onClick={handleSave}
disabled={!logicalColumnName || !physicalColumnName}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
추가
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
```
#### 3.2 카테고리 값 추가 시 논리적 컬럼 사용
**파일**: `frontend/components/admin/table-type-management/CategoryValueEditor.tsx`
```typescript
export function CategoryValueEditor({
tableName,
menuObjid,
onSuccess,
}: Props) {
const [logicalColumns, setLogicalColumns] = useState<Array<{
logical_column_name: string;
physical_column_name: string;
description: string;
}>>([]);
const [selectedLogicalColumn, setSelectedLogicalColumn] = useState("");
const [valueCode, setValueCode] = useState("");
const [valueLabel, setValueLabel] = useState("");
// 논리적 컬럼 목록 조회
useEffect(() => {
async function loadLogicalColumns() {
const response = await apiClient.get(
`/api/categories/logical-columns/${tableName}/${menuObjid}`
);
if (response.data.success) {
setLogicalColumns(response.data.data);
}
}
loadLogicalColumns();
}, [tableName, menuObjid]);
const handleSave = async () => {
await apiClient.post("/api/categories/values", {
tableName,
columnName: selectedLogicalColumn, // 논리적 컬럼명 저장
valueCode,
valueLabel,
menuObjid,
});
toast.success("카테고리 값이 추가되었습니다");
onSuccess();
};
return (
<div className="space-y-4">
{/* 논리적 컬럼 선택 */}
<div>
<Label>컬럼 선택</Label>
<Select value={selectedLogicalColumn} onValueChange={setSelectedLogicalColumn}>
<SelectTrigger>
<SelectValue placeholder="카테고리 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{logicalColumns.map((col) => (
<SelectItem key={col.logical_column_name} value={col.logical_column_name}>
<div className="flex flex-col">
<span className="font-medium">{col.logical_column_name}</span>
<span className="text-xs text-gray-500">
실제 컬럼: {col.physical_column_name}
</span>
{col.description && (
<span className="text-xs text-gray-400">{col.description}</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 카테고리 값 입력 */}
<div>
<Label>코드</Label>
<Input value={valueCode} onChange={(e) => setValueCode(e.target.value)} />
</div>
<div>
<Label>라벨</Label>
<Input value={valueLabel} onChange={(e) => setValueLabel(e.target.value)} />
</div>
<Button onClick={handleSave}>저장</Button>
</div>
);
}
```
#### 3.3 데이터 저장 시 매핑 적용
**파일**: `frontend/components/screen/InteractiveScreenViewer.tsx`
```typescript
async function saveFormData(formData: Record<string, any>) {
const menuObjid = screenConfig.menuObjid;
const tableName = screenConfig.tableName;
// 백엔드에서 자동으로 논리명 → 물리명 변환
const response = await apiClient.post(`/api/data/${tableName}`, {
menuObjid,
data: formData, // 논리적 컬럼명으로 전달
});
if (response.data.success) {
toast.success("저장되었습니다");
} else {
toast.error("저장 실패");
}
}
```
---
## 5. 사용 예시
### 예시 1: 품목 상태 컬럼 분리
**상황**: `item_info.status` 컬럼을 두 메뉴에서 다르게 사용
#### 1단계: 논리적 컬럼 생성
```
기준정보 > 품목정보 (menu_objid=103)
- 논리적 컬럼명: status_stock
- 물리적 컬럼명: status
- 카테고리 값: 정상, 대기, 품절
영업관리 > 판매품목정보 (menu_objid=203)
- 논리적 컬럼명: status_sales
- 물리적 컬럼명: status
- 카테고리 값: 판매중, 판매중지, 품절
```
#### 2단계: 데이터 입력
```typescript
// 기준정보 > 품목정보에서 입력
{
item_name: "키보드",
status_stock: "정상", // 논리적 컬럼명
}
// DB에 저장될 때
{
item_name: "키보드",
status: "정상", // 물리적 컬럼명으로 자동 변환
}
```
```typescript
// 영업관리 > 판매품목정보에서 입력
{
item_name: "마우스",
status_sales: "판매중", // 논리적 컬럼명
}
// DB에 저장될 때
{
item_name: "마우스",
status: "판매중", // 물리적 컬럼명으로 자동 변환
}
```
#### 3단계: 데이터 조회
```typescript
// 기준정보 > 품목정보에서 조회
SELECT
item_id,
item_name,
status -- 물리적 컬럼
FROM item_info
WHERE company_code = 'COMPANY_A';
// 프론트엔드에서 표시할 때 논리적 컬럼명으로 매핑
{
item_name: "키보드",
status_stock: "정상", // UI에서는 논리명 사용
}
```
---
## 6. 체크리스트
### 개발 단계
- [ ] `category_column_mapping` 테이블 생성
- [ ] 백엔드: 컬럼 매핑 조회 API
- [ ] 백엔드: 컬럼 매핑 생성 API
- [ ] 백엔드: 데이터 저장 시 논리명 → 물리명 변환 로직
- [ ] 프론트엔드: 논리적 컬럼 추가 UI
- [ ] 프론트엔드: 카테고리 값 추가 시 논리적 컬럼 선택
- [ ] 프론트엔드: 데이터 저장 시 논리적 컬럼명 사용
### 테스트 단계
- [ ] 같은 물리적 컬럼에 여러 논리적 컬럼 생성
- [ ] 메뉴별로 다른 카테고리 값 표시
- [ ] 데이터 저장 시 올바른 물리적 컬럼에 저장
- [ ] 데이터 조회 시 논리적 컬럼명으로 매핑
---
## 7. 장점 요약
### 데이터베이스
- ✅ 스키마 변경 최소화
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 유연한 컬럼 관리
### UI/UX
- ✅ 메뉴별 맞춤형 카테고리 관리
- ✅ 직관적인 논리적 컬럼명 사용
- ✅ 관리자가 쉽게 설정 가능
### 개발
- ✅ 백엔드에서 자동 매핑 처리
- ✅ 프론트엔드는 논리적 컬럼명만 사용
- ✅ 확장 가능한 아키텍처
---
## 8. 결론
**같은 물리적 컬럼을 메뉴별로 다른 카테고리로 사용하려면 "가상 컬럼 분리 (Virtual Column Mapping)" 방식이 최적입니다.**
- 논리적 컬럼명으로 메뉴별 카테고리 독립성 확보
- 물리적 컬럼명으로 실제 데이터 저장
- 매핑 테이블로 유연한 관리
이 방식은 데이터베이스 변경을 최소화하면서도 메뉴별로 완전히 독립적인 카테고리 관리를 가능하게 합니다.

View File

@ -0,0 +1,673 @@
# 카테고리 메뉴 스코프 개선 계획서
## 1. 문제 정의
### 현재 문제점
**카테고리 컴포넌트가 형제 메뉴 기반으로 카테고리를 조회하여 메뉴별 카테고리 구분 불가**
#### 구체적 상황
- `기준정보 > 품목정보` (item_info 테이블 사용)
- `영업관리 > 판매품목정보` (item_info 테이블 사용)
- 두 메뉴가 같은 테이블(`item_info`)을 사용하지만, 표시되어야 할 카테고리는 달라야 함
#### 현재 로직의 문제
```typescript
// 형제 메뉴들이 사용하는 모든 테이블의 카테고리 컬럼 조회
const siblings = await getSiblingMenus(currentMenuId);
const categories = await getCategoriesFromTables(siblings.tables);
```
**결과**:
- `기준정보 > 품목정보`에서 보이지 않아야 할 카테고리가 표시됨
- `영업관리 > 판매품목정보`에서만 보여야 할 카테고리가 `기준정보`에도 표시됨
---
## 2. 해결 방안
### 핵심 아이디어
**카테고리 값이 어떤 2레벨 메뉴에 속하는지 명시적으로 저장하고, 조회 시 2레벨 메뉴 기준으로 필터링**
### 2.1 데이터베이스 구조 (이미 준비됨)
```sql
-- table_column_category_values 테이블에 이미 menu_objid 컬럼 존재
SELECT * FROM table_column_category_values;
-- 컬럼 구조
-- value_id: PK
-- table_name: 테이블명
-- column_name: 컬럼명
-- value_code: 카테고리 코드
-- value_label: 카테고리 라벨
-- menu_objid: 2레벨 메뉴 OBJID (핵심!)
-- company_code: 회사 코드
-- ...
```
### 2.2 메뉴 계층 구조
```
1레벨 메뉴 (기준정보, objid=1)
├── 2레벨 메뉴 (회사정보, objid=101)
├── 2레벨 메뉴 (부서관리, objid=102)
└── 2레벨 메뉴 (품목정보, objid=103) ← 여기에 item_info 테이블 사용
1레벨 메뉴 (영업관리, objid=2)
├── 2레벨 메뉴 (견적관리, objid=201)
├── 2레벨 메뉴 (수주관리, objid=202)
└── 2레벨 메뉴 (판매품목정보, objid=203) ← 여기도 item_info 테이블 사용
```
### 2.3 카테고리 값 저장 방식
```sql
-- 기준정보 > 품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'STOCK_ITEM', '재고품목', 103, 'COMPANY_A'),
('item_info', 'category_type', 'ASSET', '자산', 103, 'COMPANY_A');
-- 영업관리 > 판매품목정보에서 사용할 카테고리
INSERT INTO table_column_category_values
(table_name, column_name, value_code, value_label, menu_objid, company_code)
VALUES
('item_info', 'category_type', 'SALES_ITEM', '판매품목', 203, 'COMPANY_A'),
('item_info', 'category_type', 'SERVICE', '서비스', 203, 'COMPANY_A');
```
---
## 3. 구현 단계
### Phase 1: 백엔드 API 수정
#### 3.1 카테고리 조회 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
**현재 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName } = req.params;
const companyCode = req.user!.companyCode;
// 형제 메뉴들의 테이블에서 카테고리 컬럼 조회
const siblings = await getSiblingMenuTables(menuObjid);
const query = `
SELECT DISTINCT
table_name,
column_name,
value_code,
value_label
FROM table_column_category_values
WHERE table_name IN (${siblings.join(',')})
AND company_code = $1
`;
}
```
**개선된 로직**:
```typescript
export async function getCategoryColumns(req: Request, res: Response) {
const { tableName, menuObjid } = req.params; // menuObjid 추가
const companyCode = req.user!.companyCode;
// 2레벨 메뉴 OBJID 찾기
const topLevelMenuId = await getTopLevelMenuId(menuObjid);
const query = `
SELECT
table_name,
column_name,
value_code,
value_label,
value_order,
parent_value_id,
depth,
description,
color,
icon
FROM table_column_category_values
WHERE table_name = $1
AND menu_objid = $2
AND company_code = $3
AND is_active = true
ORDER BY value_order, value_label
`;
const result = await pool.query(query, [tableName, topLevelMenuId, companyCode]);
logger.info("카테고리 컬럼 조회 (메뉴 스코프)", {
tableName,
menuObjid: topLevelMenuId,
companyCode,
categoryCount: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
}
```
#### 3.2 2레벨 메뉴 OBJID 찾기 함수
```typescript
/**
* 현재 메뉴의 최상위(2레벨) 메뉴 OBJID 찾기
*
* @param menuObjid - 현재 메뉴 OBJID
* @returns 2레벨 메뉴 OBJID
*/
async function getTopLevelMenuId(menuObjid: number): Promise<number> {
const query = `
WITH RECURSIVE menu_hierarchy AS (
-- 현재 메뉴
SELECT
objid,
parent_obj_id,
menu_type,
1 as level
FROM menu_info
WHERE objid = $1
UNION ALL
-- 부모 메뉴들 재귀 조회
SELECT
m.objid,
m.parent_obj_id,
m.menu_type,
mh.level + 1
FROM menu_info m
JOIN menu_hierarchy mh ON m.objid = mh.parent_obj_id
)
SELECT objid
FROM menu_hierarchy
WHERE parent_obj_id IS NULL OR menu_type = 1 -- 1레벨 메뉴의 자식 = 2레벨
ORDER BY level DESC
LIMIT 1;
`;
const result = await pool.query(query, [menuObjid]);
if (result.rowCount === 0) {
throw new Error(`메뉴를 찾을 수 없습니다: ${menuObjid}`);
}
return result.rows[0].objid;
}
```
#### 3.3 카테고리 값 생성 API 개선
**파일**: `backend-node/src/controllers/categoryController.ts`
```typescript
export async function createCategoryValue(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const {
tableName,
columnName,
valueCode,
valueLabel,
menuObjid, // 필수로 추가
parentValueId,
depth,
description,
color,
icon,
} = req.body;
// 입력 검증
if (!tableName || !columnName || !valueCode || !valueLabel || !menuObjid) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
}
const query = `
INSERT INTO table_column_category_values (
table_name,
column_name,
value_code,
value_label,
menu_objid,
parent_value_id,
depth,
description,
color,
icon,
company_code,
created_by,
updated_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *
`;
const result = await pool.query(query, [
tableName,
columnName,
valueCode,
valueLabel,
menuObjid,
parentValueId || null,
depth || 1,
description || null,
color || null,
icon || null,
companyCode,
req.user!.userId,
req.user!.userId,
]);
logger.info("카테고리 값 생성", {
tableName,
columnName,
valueCode,
menuObjid,
companyCode,
});
return res.json({
success: true,
data: result.rows[0],
});
}
```
### Phase 2: 프론트엔드 - 테이블 타입 관리 UI 개선
#### 2.1 테이블 타입 관리 컴포넌트 수정
**파일**: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
**추가할 기능**:
1. 카테고리 컬럼 설정 시 "적용할 메뉴 선택" 기능
2. 2레벨 메뉴 목록 조회 및 다중 선택 UI
3. 선택된 메뉴별로 카테고리 값 생성
```typescript
interface CategoryMenuScope {
menuObjid: number;
menuName: string;
parentMenuName: string;
isSelected: boolean;
}
function CategoryColumnConfig({ tableName, columnName }: Props) {
const [menuScopes, setMenuScopes] = useState<CategoryMenuScope[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
// 2레벨 메뉴 목록 조회
useEffect(() => {
async function loadMenuScopes() {
const response = await apiClient.get("/api/menus/second-level");
if (response.data.success) {
setMenuScopes(response.data.data);
}
}
loadMenuScopes();
}, []);
// 카테고리 값 저장 시 선택된 메뉴들에 대해 각각 저장
const handleSaveCategoryValue = async (categoryData: any) => {
for (const menuObjid of selectedMenus) {
await apiClient.post("/api/categories/values", {
...categoryData,
tableName,
columnName,
menuObjid, // 메뉴별로 저장
});
}
toast.success("카테고리가 선택된 메뉴에 저장되었습니다");
};
return (
<div className="space-y-4">
<Label>적용할 메뉴 선택</Label>
<div className="border rounded-lg p-4 space-y-2 max-h-60 overflow-y-auto">
{menuScopes.map((scope) => (
<div key={scope.menuObjid} className="flex items-center gap-2">
<Checkbox
checked={selectedMenus.includes(scope.menuObjid)}
onCheckedChange={(checked) => {
if (checked) {
setSelectedMenus([...selectedMenus, scope.menuObjid]);
} else {
setSelectedMenus(selectedMenus.filter(id => id !== scope.menuObjid));
}
}}
/>
<span className="text-sm">
{scope.parentMenuName} → {scope.menuName}
</span>
</div>
))}
</div>
{selectedMenus.length === 0 && (
<p className="text-xs text-destructive">
최소 하나 이상의 메뉴를 선택해주세요
</p>
)}
{/* 카테고리 값 추가 UI */}
<CategoryValueEditor
onSave={handleSaveCategoryValue}
disabled={selectedMenus.length === 0}
/>
</div>
);
}
```
#### 2.2 2레벨 메뉴 조회 API 추가
**파일**: `backend-node/src/controllers/menuController.ts`
```typescript
/**
* 2레벨 메뉴 목록 조회
* (카테고리 스코프 선택용)
*/
export async function getSecondLevelMenus(req: Request, res: Response) {
const companyCode = req.user!.companyCode;
const query = `
SELECT
m2.objid as menu_objid,
m2.menu_name_kor as menu_name,
m1.menu_name_kor as parent_menu_name,
m2.screen_code
FROM menu_info m2
JOIN menu_info m1 ON m2.parent_obj_id = m1.objid
WHERE m2.menu_type = 2 -- 2레벨 메뉴
AND (m2.company_code = $1 OR m2.company_code = '*')
AND m2.status = 'Y'
ORDER BY m1.seq, m2.seq
`;
const result = await pool.query(query, [companyCode]);
return res.json({
success: true,
data: result.rows,
});
}
```
**라우트 등록**:
```typescript
router.get("/api/menus/second-level", authenticate, getSecondLevelMenus);
```
### Phase 3: 프론트엔드 - 카테고리 컴포넌트 개선
#### 3.1 카테고리 컴포넌트 조회 로직 변경
**파일**: `frontend/lib/registry/components/category/CategoryComponent.tsx`
**현재 로직** (형제 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 형제 메뉴들의 테이블에서 카테고리 조회
const response = await apiClient.get(`/api/categories/${tableName}`);
}
loadCategories();
}, [tableName]);
```
**개선된 로직** (2레벨 메뉴 기반):
```typescript
useEffect(() => {
async function loadCategories() {
// 현재 메뉴 OBJID 가져오기
const menuObjid = screenConfig.menuObjid; // 또는 URL에서 파싱
if (!menuObjid) {
console.warn("메뉴 OBJID를 찾을 수 없습니다");
return;
}
// 2레벨 메뉴 기준으로 카테고리 조회
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
if (response.data.success) {
setCategories(response.data.data);
}
}
loadCategories();
}, [tableName, screenConfig.menuObjid]);
```
#### 3.2 API 클라이언트 함수 추가
**파일**: `frontend/lib/api/category.ts`
```typescript
/**
* 특정 메뉴 스코프의 카테고리 컬럼 조회
*
* @param tableName - 테이블명
* @param menuObjid - 메뉴 OBJID
*/
export async function getCategoriesByMenuScope(
tableName: string,
menuObjid: number
): Promise<ApiResponse<CategoryColumn[]>> {
try {
const response = await apiClient.get(
`/api/categories/${tableName}/menu/${menuObjid}`
);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
```
---
## 4. 데이터 마이그레이션
### 4.1 기존 카테고리 값에 menu_objid 설정
**문제**: 기존 카테고리 값들은 `menu_objid`가 설정되지 않음
**해결**: 관리자가 테이블 타입 관리에서 기존 카테고리 값들의 메뉴 스코프를 선택하도록 유도
#### 마이그레이션 스크립트 (선택사항)
```sql
-- db/migrations/053_backfill_category_menu_objid.sql
-- Step 1: 기존 카테고리 값 확인
SELECT
value_id,
table_name,
column_name,
value_label,
menu_objid,
company_code
FROM table_column_category_values
WHERE menu_objid IS NULL OR menu_objid = 0;
-- Step 2: 기본값 설정 (예시)
-- 관리자가 수동으로 올바른 menu_objid를 설정해야 함
UPDATE table_column_category_values
SET menu_objid = 103 -- 예: 기준정보 > 품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('STOCK_ITEM', 'ASSET')
AND (menu_objid IS NULL OR menu_objid = 0);
UPDATE table_column_category_values
SET menu_objid = 203 -- 예: 영업관리 > 판매품목정보
WHERE table_name = 'item_info'
AND column_name = 'category_type'
AND value_code IN ('SALES_ITEM', 'SERVICE')
AND (menu_objid IS NULL OR menu_objid = 0);
```
### 4.2 UI에서 menu_objid 미설정 경고
```typescript
// TableTypeManagement.tsx
function CategoryColumnList({ categoryColumns }: Props) {
const unassignedCategories = categoryColumns.filter(
(col) => !col.menuObjid || col.menuObjid === 0
);
if (unassignedCategories.length > 0) {
return (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>메뉴 스코프 미설정</AlertTitle>
<AlertDescription>
{unassignedCategories.length}개의 카테고리 값에 메뉴가 설정되지 않았습니다.
<br />
각 카테고리가 어떤 메뉴에서 사용될지 설정해주세요.
</AlertDescription>
</Alert>
);
}
return <div>...</div>;
}
```
---
## 5. 테스트 시나리오
### 5.1 기본 기능 테스트
1. **2레벨 메뉴 조회**
- `GET /api/menus/second-level`
- 기준정보, 영업관리 등 2레벨 메뉴들이 조회되는지 확인
2. **카테고리 값 생성 (메뉴 스코프 포함)**
- 테이블 타입 관리에서 카테고리 컬럼 선택
- 적용할 메뉴 선택 (예: 영업관리 > 판매품목정보)
- 카테고리 값 추가
- DB에 올바른 `menu_objid`로 저장되는지 확인
3. **카테고리 컴포넌트 조회 (메뉴별 필터링)**
- `기준정보 > 품목정보` 화면 접속
- 해당 메뉴의 카테고리만 표시되는지 확인
- `영업관리 > 판매품목정보` 화면 접속
- 다른 카테고리가 표시되는지 확인
### 5.2 엣지 케이스 테스트
1. **menu_objid 미설정 카테고리**
- 기존 카테고리 값 (menu_objid = NULL)
- 경고 메시지 표시
- 조회 시 제외되는지 확인
2. **여러 메뉴에 동일 카테고리**
- 하나의 카테고리를 여러 메뉴에 적용
- 각 메뉴에서 독립적으로 관리되는지 확인
3. **최고 관리자 권한**
- 최고 관리자는 모든 메뉴의 카테고리를 볼 수 있는지 확인
---
## 6. 롤백 계획
만약 문제가 발생하면 다음 단계로 롤백:
### 6.1 백엔드 API 롤백
```bash
git revert <commit-hash>
```
### 6.2 데이터베이스 롤백
```sql
-- menu_objid 조건 제거 (임시)
-- 기존 로직으로 돌아감
```
### 6.3 프론트엔드 롤백
- 카테고리 조회 시 `menuObjid` 파라미터 제거
- 형제 메뉴 기반 로직으로 복원
---
## 7. 구현 우선순위
### Phase 1 (필수)
1. ✅ 백엔드: `getTopLevelMenuId()` 함수 구현
2. ✅ 백엔드: 카테고리 조회 API에 `menu_objid` 필터링 추가
3. ✅ 백엔드: 카테고리 값 생성 시 `menu_objid` 필수 처리
4. ✅ 백엔드: 2레벨 메뉴 목록 조회 API 추가
### Phase 2 (필수)
5. ✅ 프론트엔드: 테이블 타입 관리에서 메뉴 선택 UI 추가
6. ✅ 프론트엔드: 카테고리 컴포넌트 조회 로직 변경
### Phase 3 (권장)
7. ⏳ 데이터 마이그레이션: 기존 카테고리 값에 `menu_objid` 설정
8. ⏳ UI: `menu_objid` 미설정 경고 표시
9. ⏳ 테스트: 시나리오별 검증
### Phase 4 (선택)
10. ⏳ 관리자 대시보드: 카테고리 사용 현황 통계
11. ⏳ 벌크 업데이트: 여러 카테고리의 메뉴 스코프 일괄 변경
---
## 8. 기대 효과
### 8.1 문제 해결
- ✅ 같은 테이블을 사용하는 메뉴들도 서로 다른 카테고리 사용 가능
- ✅ 메뉴별 카테고리 독립성 보장
- ✅ 관리자가 메뉴별로 카테고리를 명확히 제어
### 8.2 사용자 경험 개선
- ✅ 카테고리가 메뉴 문맥에 맞게 표시됨
- ✅ 불필요한 카테고리가 표시되지 않음
- ✅ 직관적인 카테고리 관리 UI
### 8.3 확장성
- ✅ 향후 메뉴별 세밀한 권한 제어 가능
- ✅ 메뉴별 카테고리 통계 및 분석 가능
- ✅ 다른 컴포넌트에도 유사한 메뉴 스코프 패턴 적용 가능
---
## 9. 참고 자료
- 데이터베이스 스키마: `table_column_category_values` 테이블
- 메뉴 구조: `menu_info` 테이블
- 카테고리 컴포넌트: `frontend/lib/registry/components/category/CategoryComponent.tsx`
- 테이블 타입 관리: `frontend/components/admin/table-type-management/TableTypeManagement.tsx`
- API 클라이언트: `frontend/lib/api/category.ts`
---
## 10. 결론
사용자가 제안한 **"테이블 타입 관리에서 카테고리 컬럼 설정 시 2레벨 메뉴 선택"** 방식이 가장 현실적이고 효과적인 해결책입니다.
**핵심 변경사항**:
1. 카테고리 값 저장 시 `menu_objid` 필수 입력
2. 카테고리 조회 시 2레벨 메뉴 기준 필터링 (형제 메뉴 기준 제거)
3. 테이블 타입 관리 UI에서 메뉴 선택 기능 추가
이 방식으로 같은 테이블을 사용하는 서로 다른 메뉴들이 각자의 카테고리를 독립적으로 관리할 수 있습니다.

View File

@ -17,6 +17,7 @@ import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl"; import { ddlApi } from "@/lib/api/ddl";
import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue";
import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@ -54,6 +55,14 @@ interface ColumnTypeInfo {
referenceTable?: string; referenceTable?: string;
referenceColumn?: string; referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
}
interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
} }
export default function TableManagementPage() { export default function TableManagementPage() {
@ -89,6 +98,9 @@ export default function TableManagementPage() {
const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create"); const [duplicateModalMode, setDuplicateModalMode] = useState<"create" | "duplicate">("create");
const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null); const [duplicateSourceTable, setDuplicateSourceTable] = useState<string | null>(null);
// 🆕 Category 타입용: 2레벨 메뉴 목록
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
// 로그 뷰어 상태 // 로그 뷰어 상태
const [logViewerOpen, setLogViewerOpen] = useState(false); const [logViewerOpen, setLogViewerOpen] = useState(false);
const [logViewerTableName, setLogViewerTableName] = useState<string>(""); const [logViewerTableName, setLogViewerTableName] = useState<string>("");
@ -224,6 +236,22 @@ export default function TableManagementPage() {
} }
}; };
// 🆕 2레벨 메뉴 목록 로드
const loadSecondLevelMenus = async () => {
try {
const response = await getSecondLevelMenus();
if (response.success && response.data) {
setSecondLevelMenus(response.data);
} else {
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
}
} catch (error) {
console.error("❌ 2레벨 메뉴 로드 에러:", error);
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
}
};
// 테이블 목록 로드 // 테이블 목록 로드
const loadTables = async () => { const loadTables = async () => {
setLoading(true); setLoading(true);
@ -257,10 +285,17 @@ export default function TableManagementPage() {
if (response.data.success) { if (response.data.success) {
const data = response.data.data; const data = response.data.data;
console.log("📥 원본 API 응답:", {
hasColumns: !!(data.columns || data),
firstColumn: (data.columns || data)[0],
statusColumn: (data.columns || data).find((col: any) => col.columnName === "status"),
});
// 컬럼 데이터에 기본값 설정 // 컬럼 데이터에 기본값 설정
const processedColumns = (data.columns || data).map((col: any) => ({ const processedColumns = (data.columns || data).map((col: any) => ({
...col, ...col,
inputType: col.inputType || "text", // 기본값: text inputType: col.inputType || "text", // 기본값: text
categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
})); }));
if (page === 1) { if (page === 1) {
@ -438,12 +473,72 @@ export default function TableManagementPage() {
// console.log("저장할 컬럼 설정:", columnSetting); // console.log("저장할 컬럼 설정:", columnSetting);
console.log("💾 저장할 컬럼 정보:", {
columnName: column.columnName,
inputType: column.inputType,
categoryMenus: column.categoryMenus,
hasCategoryMenus: !!column.categoryMenus,
categoryMenusLength: column.categoryMenus?.length || 0,
});
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [ const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
columnSetting, columnSetting,
]); ]);
if (response.data.success) { if (response.data.success) {
toast.success("컬럼 설정이 성공적으로 저장되었습니다."); console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 생성
console.log("🔍 카테고리 조건 체크:", {
isCategory: column.inputType === "category",
hasCategoryMenus: !!column.categoryMenus,
length: column.categoryMenus?.length || 0,
});
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) {
console.log("📥 카테고리 메뉴 매핑 시작:", {
columnName: column.columnName,
categoryMenus: column.categoryMenus,
count: column.categoryMenus.length,
});
let successCount = 0;
let failCount = 0;
for (const menuObjid of column.categoryMenus) {
try {
const mappingResponse = await createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.columnName,
physicalColumnName: column.columnName,
menuObjid,
description: `${column.displayName} (메뉴별 카테고리)`,
});
if (mappingResponse.success) {
successCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
failCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
failCount++;
}
}
if (successCount > 0 && failCount === 0) {
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
} else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
} else if (failCount > 0) {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
}
} else {
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
}
// 원본 데이터 업데이트 // 원본 데이터 업데이트
setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col))); setOriginalColumns((prev) => prev.map((col) => (col.columnName === column.columnName ? column : col)));
@ -501,14 +596,78 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성
const categoryColumns = columns.filter(
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
);
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length,
categoryColumns: categoryColumns.length,
categoryColumnsData: categoryColumns.map((col) => ({
columnName: col.columnName,
categoryMenus: col.categoryMenus,
})),
});
if (categoryColumns.length > 0) {
let totalSuccessCount = 0;
let totalFailCount = 0;
for (const column of categoryColumns) {
for (const menuObjid of column.categoryMenus!) {
try {
console.log("🔄 매핑 API 호출:", {
tableName: selectedTable,
columnName: column.columnName,
menuObjid,
});
const mappingResponse = await createColumnMapping({
tableName: selectedTable,
logicalColumnName: column.columnName,
physicalColumnName: column.columnName,
menuObjid,
description: `${column.displayName} (메뉴별 카테고리)`,
});
console.log("✅ 매핑 API 응답:", mappingResponse);
if (mappingResponse.success) {
totalSuccessCount++;
} else {
console.error("❌ 매핑 생성 실패:", mappingResponse);
totalFailCount++;
}
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
}
}
}
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
if (totalSuccessCount > 0) {
toast.success(
`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`
);
} else if (totalFailCount > 0) {
toast.warning(`테이블 설정은 저장되었으나 ${totalFailCount}개 메뉴 매핑 생성 실패.`);
} else {
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
}
} else {
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
}
// 저장 성공 후 원본 데이터 업데이트 // 저장 성공 후 원본 데이터 업데이트
setOriginalColumns([...columns]); setOriginalColumns([...columns]);
toast.success(`테이블 '${selectedTable}' 설정이 모두 저장되었습니다.`);
// 테이블 목록 새로고침 (라벨 변경 반영) // 테이블 목록 새로고침 (라벨 변경 반영)
loadTables(); loadTables();
// 저장 후 데이터 확인을 위해 다시 로드 // 저장 후 데이터 다시 로드
setTimeout(() => { setTimeout(() => {
loadColumnTypes(selectedTable, 1, pageSize); loadColumnTypes(selectedTable, 1, pageSize);
}, 1000); }, 1000);
@ -539,6 +698,7 @@ export default function TableManagementPage() {
useEffect(() => { useEffect(() => {
loadTables(); loadTables();
loadCommonCodeCategories(); loadCommonCodeCategories();
loadSecondLevelMenus();
}, []); }, []);
// 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드 // 🎯 컬럼 로드 후 이미 설정된 참조 테이블들의 컬럼 정보 로드
@ -1023,10 +1183,61 @@ export default function TableManagementPage() {
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
{/* 입력 타입이 'category'인 경우 안내 메시지 */} {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
{column.inputType === "category" && ( {column.inputType === "category" && (
<div className="flex items-center h-8 text-xs text-muted-foreground"> <div className="space-y-2">
<label className="text-muted-foreground mb-1 block text-xs">
(2)
</label>
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
setColumns((prev) =>
prev.map((col) =>
col.columnName === column.columnName
? { ...col, categoryMenus: newMenus }
: col
)
);
}}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
/>
<label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
);
})
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)}
</div> </div>
)} )}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}

View File

@ -161,6 +161,7 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
menuUrl: screenUrl, menuUrl: screenUrl,
screenCode: screen.screenCode, // 화면 코드도 함께 저장
})); }));
// console.log("🖥️ 화면 선택 완료:", { // console.log("🖥️ 화면 선택 완료:", {
@ -207,10 +208,11 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
if (type === "direct") { if (type === "direct") {
// 직접 입력 모드로 변경 시 선택된 화면 초기화 // 직접 입력 모드로 변경 시 선택된 화면 초기화
setSelectedScreen(null); setSelectedScreen(null);
// URL 필드 초기화 (사용자가 직접 입력할 수 있도록) // URL 필드와 screenCode 초기화 (사용자가 직접 입력할 수 있도록)
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
menuUrl: "", menuUrl: "",
screenCode: undefined, // 화면 코드도 함께 초기화
})); }));
} else { } else {
// 화면 할당 모드로 변경 시 // 화면 할당 모드로 변경 시
@ -230,12 +232,14 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
menuUrl: screenUrl, menuUrl: screenUrl,
screenCode: selectedScreen.screenCode, // 화면 코드도 함께 유지
})); }));
} else { } else {
// 선택된 화면이 없으면 URL 초기화 // 선택된 화면이 없으면 URL과 screenCode 초기화
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
menuUrl: "", menuUrl: "",
screenCode: undefined,
})); }));
} }
} }

View File

@ -1,18 +1,22 @@
"use client"; "use client";
import React, { useState, useCallback, useEffect } from "react"; import React, { useState, useCallback, useEffect, useMemo } from "react";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, ChevronRight, ChevronDown } from "lucide-react"; import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search, ChevronRight, ChevronDown, ChevronsDown, ChevronsUp, BookOpen, Shield, Eye, CheckSquare, Building2 } from "lucide-react";
import { RoleGroup, roleAPI } from "@/lib/api/role"; import { RoleGroup, roleAPI } from "@/lib/api/role";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { cn } from "@/lib/utils";
interface MenuPermission { interface MenuPermission {
menuObjid: number; menuObjid: number;
menuName: string; menuName: string;
menuPath?: string; menuPath?: string;
parentObjid?: number; parentObjid?: number;
companyCode?: string;
createYn: string; createYn: string;
readYn: string; readYn: string;
updateYn: string; updateYn: string;
@ -41,10 +45,37 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set()); const [expandedMenus, setExpandedMenus] = useState<Set<number>>(new Set());
const [allMenus, setAllMenus] = useState<any[]>([]); const [allMenus, setAllMenus] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// 최고 관리자 전용: 회사별 필터
const [companyFilter, setCompanyFilter] = useState<string>("all"); // 초기값: 모든 메뉴
const [companyInfo, setCompanyInfo] = useState<{ code: string; name: string } | null>(null);
// 메뉴 타입 필터 (관리자/사용자)
const [menuTypeFilter, setMenuTypeFilter] = useState<string>("all");
// 최고 관리자 여부 확인 // 최고 관리자 여부 확인
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN"; const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
// 회사 정보 가져오기
useEffect(() => {
const fetchCompanyInfo = async () => {
if (roleGroup.companyCode && roleGroup.companyCode !== "*") {
try {
const { companyAPI } = await import("@/lib/api/company");
const company = await companyAPI.getInfo(roleGroup.companyCode);
setCompanyInfo({
code: company.companyCode,
name: company.companyName,
});
} catch (error) {
console.error("회사 정보 로드 실패", error);
}
}
};
fetchCompanyInfo();
}, [roleGroup.companyCode]);
// 전체 메뉴 목록 로드 // 전체 메뉴 목록 로드
useEffect(() => { useEffect(() => {
// currentUser가 로드될 때까지 대기 // currentUser가 로드될 때까지 대기
@ -54,9 +85,15 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
} }
const loadAllMenus = async () => { const loadAllMenus = async () => {
// 최고 관리자: companyCode 없이 모든 메뉴 조회 let targetCompanyCode: string | undefined;
// 회사 관리자: 자기 회사 메뉴만 조회
const targetCompanyCode = isSuperAdmin ? undefined : roleGroup.companyCode; if (isSuperAdmin) {
// 최고 관리자: 권한그룹의 회사 코드로 조회 (해당 회사 + 공통 메뉴 모두 반환)
targetCompanyCode = roleGroup.companyCode;
} else {
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
targetCompanyCode = roleGroup.companyCode;
}
console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", { console.log("🔍 [MenuPermissionsTable] 전체 메뉴 로드 시작", {
currentUser: { currentUser: {
@ -66,7 +103,8 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
}, },
isSuperAdmin, isSuperAdmin,
roleGroupCompanyCode: roleGroup.companyCode, roleGroupCompanyCode: roleGroup.companyCode,
targetCompanyCode: targetCompanyCode || "전체", companyFilter,
targetCompanyCode,
}); });
try { try {
@ -81,6 +119,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
if (response.success && response.data) { if (response.success && response.data) {
setAllMenus(response.data); setAllMenus(response.data);
console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", { console.log("✅ [MenuPermissionsTable] 메뉴 상태 업데이트 완료", {
count: response.data.length, count: response.data.length,
}); });
@ -93,7 +132,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
}; };
loadAllMenus(); loadAllMenus();
}, [currentUser, isSuperAdmin, roleGroup.companyCode]); }, [currentUser, isSuperAdmin, roleGroup.companyCode, companyFilter]);
// 메뉴 권한 상태 (로컬 상태 관리) // 메뉴 권한 상태 (로컬 상태 관리)
const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map()); const [menuPermissions, setMenuPermissions] = useState<Map<number, MenuPermission>>(new Map());
@ -108,11 +147,16 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
// 기존 권한이 있으면 사용, 없으면 기본값 // 기존 권한이 있으면 사용, 없으면 기본값
const existingPermission = permissions.find((p) => p.menuObjid === menu.objid); const existingPermission = permissions.find((p) => p.menuObjid === menu.objid);
permissionsMap.set(menu.objid, { // objid를 숫자로 변환하여 저장
menuObjid: menu.objid, const menuObjid = Number(menu.objid);
const parentObjid = menu.parentObjid ? Number(menu.parentObjid) : 0;
permissionsMap.set(menuObjid, {
menuObjid,
menuName: menu.menuName, menuName: menu.menuName,
menuPath: menu.menuUrl, menuPath: menu.menuUrl,
parentObjid: menu.parentObjid, parentObjid,
companyCode: menu.companyCode,
createYn: existingPermission?.createYn || "N", createYn: existingPermission?.createYn || "N",
readYn: existingPermission?.readYn || "N", readYn: existingPermission?.readYn || "N",
updateYn: existingPermission?.updateYn || "N", updateYn: existingPermission?.updateYn || "N",
@ -136,8 +180,159 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
} }
}, [menuPermissions, isInitialized, onPermissionsChange]); }, [menuPermissions, isInitialized, onPermissionsChange]);
// 메뉴 트리 구조 생성 및 필터링
const buildMenuTree = useCallback((menus: MenuPermission[]): MenuPermission[] => {
const menuMap = new Map<number, MenuPermission>();
const rootMenus: MenuPermission[] = [];
// 먼저 모든 메뉴를 Map에 저장
menus.forEach((menu) => {
menuMap.set(menu.menuObjid, { ...menu, children: [] });
});
// 부모-자식 관계 구성
menuMap.forEach((menu) => {
// parentObjid를 숫자로 변환하여 비교
const parentId = Number(menu.parentObjid);
if (!menu.parentObjid || parentId === 0 || isNaN(parentId)) {
rootMenus.push(menu);
} else {
const parent = menuMap.get(parentId);
if (parent) {
parent.children = parent.children || [];
parent.children.push(menu);
} else {
// 부모를 찾을 수 없으면 최상위로 처리
console.warn("⚠️ 부모 메뉴를 찾을 수 없음", {
menuObjid: menu.menuObjid,
menuName: menu.menuName,
parentObjid: menu.parentObjid,
parentId,
});
rootMenus.push(menu);
}
}
});
return rootMenus;
}, []);
// 검색 필터링
const filterMenus = useCallback(
(menus: MenuPermission[], searchText: string): MenuPermission[] => {
if (!searchText.trim()) {
return menus;
}
const search = searchText.toLowerCase();
const filtered: MenuPermission[] = [];
const matchesSearch = (menu: MenuPermission): boolean => {
return menu.menuName.toLowerCase().includes(search);
};
const filterRecursive = (menu: MenuPermission): MenuPermission | null => {
const matches = matchesSearch(menu);
const filteredChildren = (menu.children || [])
.map((child) => filterRecursive(child))
.filter((child): child is MenuPermission => child !== null);
if (matches || filteredChildren.length > 0) {
return {
...menu,
children: filteredChildren,
};
}
return null;
};
menus.forEach((menu) => {
const result = filterRecursive(menu);
if (result) {
filtered.push(result);
}
});
return filtered;
},
[],
);
// 메뉴 트리 구조 생성 (menuPermissions에서) // 메뉴 트리 구조 생성 (menuPermissions에서)
const menuTree: MenuPermission[] = Array.from(menuPermissions.values()); const menuTree = useMemo(() => {
let allMenusArray = Array.from(menuPermissions.values());
// 회사 필터링 (최고 관리자 전용)
if (isSuperAdmin && companyFilter !== "all") {
// 특정 회사 또는 공통 메뉴만 선택한 경우
allMenusArray = allMenusArray.filter(menu => menu.companyCode === companyFilter);
}
// 메뉴 타입 필터링 (관리자/사용자)
if (menuTypeFilter !== "all") {
const targetMenuType = menuTypeFilter; // "0" (관리자) 또는 "1" (사용자)
allMenusArray = allMenusArray.filter(menu => {
// 백엔드에서 받은 menuType을 비교 (allMenus에서 가져와야 함)
const originalMenu = allMenus.find(m => Number(m.objid) === menu.menuObjid);
return originalMenu && String(originalMenu.menuType) === targetMenuType;
});
}
console.log("🌲 [MenuTree] 트리 생성 시작", {
allMenusCount: allMenusArray.length,
searchText,
menuTypeFilter,
sampleMenu: allMenusArray[0],
});
const tree = buildMenuTree(allMenusArray);
console.log("🌲 [MenuTree] 빌드 완료", {
rootMenusCount: tree.length,
rootMenus: tree.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName, parentObjid: m.parentObjid })),
});
const filtered = filterMenus(tree, searchText);
console.log("🌲 [MenuTree] 필터링 완료", {
filteredCount: filtered.length,
filtered: filtered.slice(0, 5).map(m => ({ objid: m.menuObjid, name: m.menuName })),
});
return filtered;
}, [menuPermissions, searchText, menuTypeFilter, allMenus, isSuperAdmin, companyFilter, buildMenuTree, filterMenus]);
// 통계 계산
const statistics = useMemo(() => {
let totalMenus = 0;
let menusWithPermissions = 0;
menuPermissions.forEach((menu) => {
totalMenus++;
if (menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y") {
menusWithPermissions++;
}
});
return { totalMenus, menusWithPermissions };
}, [menuPermissions]);
// 전체 펼치기/접기
const expandAll = useCallback(() => {
const allIds = new Set<number>();
const collectIds = (menu: MenuPermission) => {
if (menu.children && menu.children.length > 0) {
allIds.add(menu.menuObjid);
menu.children.forEach(collectIds);
}
};
menuTree.forEach(collectIds);
setExpandedMenus(allIds);
}, [menuTree]);
const collapseAll = useCallback(() => {
setExpandedMenus(new Set());
}, []);
// 메뉴 펼치기/접기 토글 // 메뉴 펼치기/접기 토글
const toggleExpand = useCallback((menuObjid: number) => { const toggleExpand = useCallback((menuObjid: number) => {
@ -152,26 +347,52 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
}); });
}, []); }, []);
// 권한 변경 핸들러 // 하위 메뉴 ID 수집
const collectChildIds = useCallback((menu: MenuPermission): number[] => {
const ids = [menu.menuObjid];
if (menu.children) {
menu.children.forEach((child) => {
ids.push(...collectChildIds(child));
});
}
return ids;
}, []);
// 권한 변경 핸들러 (하위 메뉴 포함)
const handlePermissionChange = useCallback( const handlePermissionChange = useCallback(
(menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean) => { (menuObjid: number, permission: "createYn" | "readYn" | "updateYn" | "deleteYn", checked: boolean, applyToChildren = true) => {
setMenuPermissions((prev) => { setMenuPermissions((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const menuPerm = newMap.get(menuObjid); const menuPerm = newMap.get(menuObjid);
if (menuPerm) { if (!menuPerm) return newMap;
newMap.set(menuObjid, {
...menuPerm, // 현재 메뉴 권한 변경
[permission]: checked ? "Y" : "N", newMap.set(menuObjid, {
...menuPerm,
[permission]: checked ? "Y" : "N",
});
// 하위 메뉴에도 적용 (옵션)
if (applyToChildren && menuPerm.children && menuPerm.children.length > 0) {
const childIds = collectChildIds(menuPerm);
childIds.forEach((childId) => {
const childPerm = newMap.get(childId);
if (childPerm) {
newMap.set(childId, {
...childPerm,
[permission]: checked ? "Y" : "N",
});
}
}); });
} }
return newMap; return newMap;
}); });
console.log("✅ 권한 변경:", { menuObjid, permission, checked }); console.log("✅ 권한 변경:", { menuObjid, permission, checked, applyToChildren });
}, },
[], [collectChildIds],
); );
// 전체 선택/해제 // 전체 선택/해제
@ -195,29 +416,142 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
[], [],
); );
// 필터링된 메뉴 ID 수집 (재귀)
const collectFilteredMenuIds = useCallback((menus: MenuPermission[]): Set<number> => {
const ids = new Set<number>();
const traverse = (menu: MenuPermission) => {
ids.add(menu.menuObjid);
if (menu.children) {
menu.children.forEach(traverse);
}
};
menus.forEach(traverse);
return ids;
}, []);
// 빠른 권한 설정 프리셋 (필터링된 메뉴만)
const applyPreset = useCallback((preset: "read-only" | "full" | "none") => {
// 현재 필터링된 메뉴 ID 수집
const filteredIds = collectFilteredMenuIds(menuTree);
console.log("🎯 프리셋 적용 대상:", {
preset,
filteredCount: filteredIds.size,
filteredIds: Array.from(filteredIds),
});
setMenuPermissions((prev) => {
const newMap = new Map(prev);
// 필터링된 메뉴만 권한 변경
filteredIds.forEach((menuObjid) => {
const menuPerm = newMap.get(menuObjid);
if (!menuPerm) return;
switch (preset) {
case "read-only":
newMap.set(menuObjid, {
...menuPerm,
createYn: "N",
readYn: "Y",
updateYn: "N",
deleteYn: "N",
});
break;
case "full":
newMap.set(menuObjid, {
...menuPerm,
createYn: "Y",
readYn: "Y",
updateYn: "Y",
deleteYn: "Y",
});
break;
case "none":
newMap.set(menuObjid, {
...menuPerm,
createYn: "N",
readYn: "N",
updateYn: "N",
deleteYn: "N",
});
break;
}
});
return newMap;
});
console.log("✅ 프리셋 적용 완료:", { preset, count: filteredIds.size });
}, [menuTree, collectFilteredMenuIds]);
// 회사 코드에서 회사명 가져오기
const getCompanyLabel = useCallback((code: string) => {
if (code === "*") {
return "공통";
}
// 현재 권한그룹의 회사 코드와 일치하면 회사명 표시
if (companyInfo && code === companyInfo.code) {
return companyInfo.name;
}
// 그 외에는 회사 코드 표시
return code;
}, [companyInfo]);
// 메뉴 행 렌더링 // 메뉴 행 렌더링
const renderMenuRow = (menu: MenuPermission, level: number = 0) => { const renderMenuRow = (menu: MenuPermission, level: number = 0) => {
const hasChildren = menu.children && menu.children.length > 0; const hasChildren = menu.children && menu.children.length > 0;
const isExpanded = expandedMenus.has(menu.menuObjid); const isExpanded = expandedMenus.has(menu.menuObjid);
const paddingLeft = level * 24; const paddingLeft = level * 24;
const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
return ( return (
<React.Fragment key={menu.menuObjid}> <React.Fragment key={menu.menuObjid}>
<TableRow className="hover:bg-muted/50 transition-colors"> <TableRow
className={cn(
"hover:bg-muted/50 transition-colors",
hasAnyPermission && "bg-primary/5 border-l-2 border-l-primary",
)}
>
{/* 메뉴명 */} {/* 메뉴명 */}
<TableCell className="h-16 text-sm" style={{ paddingLeft: `${paddingLeft + 16}px` }}> <TableCell className="h-12 text-sm py-2" style={{ paddingLeft: `${paddingLeft + 16}px` }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasChildren && ( {hasChildren && (
<button onClick={() => toggleExpand(menu.menuObjid)} className="transition-transform"> <button
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />} onClick={() => toggleExpand(menu.menuObjid)}
className="p-1 hover:bg-muted rounded transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button> </button>
)} )}
<span className={`text-sm ${hasChildren ? "font-semibold" : "font-medium"}`}>{menu.menuName}</span> {!hasChildren && <div className="w-6" />}
<span className={cn("text-sm", hasChildren ? "font-semibold" : "font-medium", hasAnyPermission && "text-primary")}>
{menu.menuName}
</span>
{menu.companyCode && (
<span
className={cn(
"px-1.5 py-0.5 text-[10px] font-medium rounded",
menu.companyCode === "*"
? "bg-primary/10 text-primary border border-primary/20"
: "bg-muted text-muted-foreground border border-border"
)}
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
>
{getCompanyLabel(menu.companyCode)}
</span>
)}
</div> </div>
</TableCell> </TableCell>
{/* 생성(Create) */} {/* 생성(Create) */}
<TableCell className="h-16 text-center text-sm"> <TableCell className="h-12 text-center text-sm py-2">
<div className="flex justify-center"> <div className="flex justify-center">
<Checkbox <Checkbox
checked={menu.createYn === "Y"} checked={menu.createYn === "Y"}
@ -227,7 +561,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
</TableCell> </TableCell>
{/* 조회(Read) */} {/* 조회(Read) */}
<TableCell className="h-16 text-center text-sm"> <TableCell className="h-12 text-center text-sm py-2">
<div className="flex justify-center"> <div className="flex justify-center">
<Checkbox <Checkbox
checked={menu.readYn === "Y"} checked={menu.readYn === "Y"}
@ -237,7 +571,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
</TableCell> </TableCell>
{/* 수정(Update) */} {/* 수정(Update) */}
<TableCell className="h-16 text-center text-sm"> <TableCell className="h-12 text-center text-sm py-2">
<div className="flex justify-center"> <div className="flex justify-center">
<Checkbox <Checkbox
checked={menu.updateYn === "Y"} checked={menu.updateYn === "Y"}
@ -247,7 +581,7 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
</TableCell> </TableCell>
{/* 삭제(Delete) */} {/* 삭제(Delete) */}
<TableCell className="h-16 text-center text-sm"> <TableCell className="h-12 text-center text-sm py-2">
<div className="flex justify-center"> <div className="flex justify-center">
<Checkbox <Checkbox
checked={menu.deleteYn === "Y"} checked={menu.deleteYn === "Y"}
@ -265,105 +599,274 @@ export function MenuPermissionsTable({ permissions, onPermissionsChange, roleGro
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 검색 */} {/* 필터 영역 (통합) */}
<div className="flex items-center gap-4"> <div className="flex flex-col gap-3 rounded-lg border bg-muted/30 p-3 sm:flex-row sm:items-center sm:justify-between">
{/* 왼쪽: 필터들 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* 최고 관리자 전용: 회사 필터 */}
{isSuperAdmin && (
<>
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">:</span>
<Select value={companyFilter} onValueChange={setCompanyFilter}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue placeholder="회사 선택" />
</SelectTrigger>
<SelectContent>
{/* 전체 메뉴 */}
<SelectItem value="all"> </SelectItem>
{/* 권한그룹의 회사 */}
{roleGroup.companyCode && roleGroup.companyCode !== "*" && companyInfo && (
<SelectItem value={roleGroup.companyCode}>
{companyInfo.name}
</SelectItem>
)}
{/* 공통 메뉴 */}
<SelectItem value="*"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 구분선 */}
<div className="hidden h-6 w-px bg-border sm:block"></div>
</>
)}
{/* 메뉴 타입 필터 (모든 사용자) */}
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">:</span>
<Select value={menuTypeFilter} onValueChange={setMenuTypeFilter}>
<SelectTrigger className="h-8 w-[160px] text-xs">
<SelectValue placeholder="메뉴 타입" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> </SelectItem>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 오른쪽: 메뉴 개수 */}
<div className="text-xs text-muted-foreground">
({statistics.totalMenus} )
</div>
</div>
{/* 통계 및 빠른 액션 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">
: <span className="font-semibold text-foreground">{statistics.totalMenus}</span>
</span>
</div>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<span className="text-muted-foreground">
: <span className="font-semibold text-primary">{statistics.menusWithPermissions}</span>
</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => applyPreset("read-only")} className="gap-1.5">
<Eye className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={() => applyPreset("full")} className="gap-1.5">
<CheckSquare className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={() => applyPreset("none")} className="gap-1.5">
</Button>
</div>
</div>
{/* 검색 및 트리 제어 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-[400px]"> <div className="relative flex-1 sm:max-w-[400px]">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="메뉴 검색..." placeholder="메뉴 검색..."
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
className="h-10 pl-10 text-sm" className="h-9 pl-10 text-sm"
/> />
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={expandAll} className="gap-1.5">
<ChevronsDown className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={collapseAll} className="gap-1.5">
<ChevronsUp className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
{/* 안내 메시지 */}
{searchText && menuTree.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Search className="h-12 w-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
"{searchText}" .
</p>
</div>
)}
{!searchText && menuTree.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center">
<BookOpen className="h-12 w-12 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
.
</p>
</div>
)}
{/* 데스크톱 테이블 */} {/* 데스크톱 테이블 */}
<div className="bg-card hidden shadow-sm lg:block"> {menuTree.length > 0 && (
<Table> <div className="bg-card hidden rounded-lg border shadow-sm lg:block">
<TableHeader> <Table>
<TableRow> <TableHeader>
<TableHead className="h-12 w-[40%] text-sm font-semibold"></TableHead> <TableRow className="bg-muted/30">
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold"> <TableHead className="h-11 w-[40%] text-xs font-semibold"></TableHead>
<div className="flex flex-col items-center gap-1"> <TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
<span> (C)</span> <div className="flex flex-col items-center gap-1.5">
<Checkbox <span> (C)</span>
onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)} <Checkbox
className="mt-1" onCheckedChange={(checked) => handleSelectAll("createYn", checked as boolean)}
/> className="data-[state=checked]:bg-green-600"
</div> />
</TableHead> </div>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold"> </TableHead>
<div className="flex flex-col items-center gap-1"> <TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
<span> (R)</span> <div className="flex flex-col items-center gap-1.5">
<Checkbox <span> (R)</span>
onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)} <Checkbox
className="mt-1" onCheckedChange={(checked) => handleSelectAll("readYn", checked as boolean)}
/> className="data-[state=checked]:bg-blue-600"
</div> />
</TableHead> </div>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold"> </TableHead>
<div className="flex flex-col items-center gap-1"> <TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
<span> (U)</span> <div className="flex flex-col items-center gap-1.5">
<Checkbox <span> (U)</span>
onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)} <Checkbox
className="mt-1" onCheckedChange={(checked) => handleSelectAll("updateYn", checked as boolean)}
/> className="data-[state=checked]:bg-amber-600"
</div> />
</TableHead> </div>
<TableHead className="h-12 w-[15%] text-center text-sm font-semibold"> </TableHead>
<div className="flex flex-col items-center gap-1"> <TableHead className="h-11 w-[15%] text-center text-xs font-semibold">
<span> (D)</span> <div className="flex flex-col items-center gap-1.5">
<Checkbox <span> (D)</span>
onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)} <Checkbox
className="mt-1" onCheckedChange={(checked) => handleSelectAll("deleteYn", checked as boolean)}
/> className="data-[state=checked]:bg-red-600"
</div> />
</TableHead> </div>
</TableRow> </TableHead>
</TableHeader> </TableRow>
<TableBody>{menuTree.map((menu) => renderMenuRow(menu))}</TableBody> </TableHeader>
</Table> <TableBody>
</div> {isLoading ? (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center">
<div className="flex flex-col items-center gap-2">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</TableCell>
</TableRow>
) : (
menuTree.map((menu) => renderMenuRow(menu))
)}
</TableBody>
</Table>
</div>
)}
{/* 모바일 카드 뷰 */} {/* 모바일 카드 뷰 */}
<div className="grid gap-4 lg:hidden"> {menuTree.length > 0 && (
{menuTree.map((menu) => ( <div className="grid gap-3 lg:hidden">
<div key={menu.menuObjid} className="bg-card p-4 shadow-sm"> {menuTree.map((menu) => {
<h3 className="mb-3 text-base font-semibold">{menu.menuName}</h3> const hasAnyPermission = menu.createYn === "Y" || menu.readYn === "Y" || menu.updateYn === "Y" || menu.deleteYn === "Y";
<div className="space-y-2">
<div className="flex items-center justify-between text-sm"> return (
<span className="text-muted-foreground"> (C)</span> <div
<Checkbox key={menu.menuObjid}
checked={menu.createYn === "Y"} className={cn(
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)} "bg-card rounded-lg border p-4 shadow-sm",
/> hasAnyPermission && "border-l-4 border-l-primary",
)}
>
<div className="mb-3 flex items-center gap-2">
<h3 className={cn("text-sm font-semibold", hasAnyPermission && "text-primary")}>
{menu.menuName}
</h3>
{menu.companyCode && (
<span
className={cn(
"px-1.5 py-0.5 text-[10px] font-medium rounded",
menu.companyCode === "*"
? "bg-primary/10 text-primary border border-primary/20"
: "bg-muted text-muted-foreground border border-border"
)}
title={menu.companyCode === "*" ? "최고 관리자 전용 메뉴" : `회사: ${getCompanyLabel(menu.companyCode)}`}
>
{getCompanyLabel(menu.companyCode)}
</span>
)}
</div>
<div className="space-y-2.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (C)</span>
<Checkbox
checked={menu.createYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "createYn", checked as boolean)}
className="data-[state=checked]:bg-green-600"
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (R)</span>
<Checkbox
checked={menu.readYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
className="data-[state=checked]:bg-blue-600"
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (U)</span>
<Checkbox
checked={menu.updateYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
className="data-[state=checked]:bg-amber-600"
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (D)</span>
<Checkbox
checked={menu.deleteYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
className="data-[state=checked]:bg-red-600"
/>
</div>
</div>
</div> </div>
<div className="flex items-center justify-between text-sm"> );
<span className="text-muted-foreground"> (R)</span> })}
<Checkbox </div>
checked={menu.readYn === "Y"} )}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "readYn", checked as boolean)}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (U)</span>
<Checkbox
checked={menu.updateYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "updateYn", checked as boolean)}
/>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> (D)</span>
<Checkbox
checked={menu.deleteYn === "Y"}
onCheckedChange={(checked) => handlePermissionChange(menu.menuObjid, "deleteYn", checked as boolean)}
/>
</div>
</div>
</div>
))}
</div>
</div> </div>
); );
} }

View File

@ -13,10 +13,33 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Loader2, Copy } from "lucide-react"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Loader2, Copy, Link as LinkIcon, Trash2, AlertCircle } from "lucide-react";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { apiClient } from "@/lib/api/client";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
import { Alert, AlertDescription } from "@/components/ui/alert";
interface LinkedModalScreen {
screenId: number;
screenName: string;
screenCode: string;
newScreenName?: string;
newScreenCode?: string;
}
interface CompanyInfo {
companyCode: string;
companyName: string;
}
interface CopyScreenModalProps { interface CopyScreenModalProps {
isOpen: boolean; isOpen: boolean;
@ -25,36 +48,279 @@ interface CopyScreenModalProps {
onCopySuccess: () => void; onCopySuccess: () => void;
} }
export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopySuccess }: CopyScreenModalProps) { export default function CopyScreenModal({
isOpen,
onClose,
sourceScreen,
onCopySuccess,
}: CopyScreenModalProps) {
const { user } = useAuth();
// 최고 관리자 판별: userType이 "SUPER_ADMIN" 또는 companyCode가 "*"
const isSuperAdmin = user?.userType === "SUPER_ADMIN" || user?.companyCode === "*";
// 디버깅: 사용자 정보 확인
useEffect(() => {
console.log("🔍 CopyScreenModal - User Info:", {
user,
isSuperAdmin,
userType: user?.userType,
companyCode: user?.companyCode,
조건1: user?.userType === "SUPER_ADMIN",
조건2: user?.companyCode === "*",
최종판별: user?.userType === "SUPER_ADMIN" || user?.companyCode === "*",
});
}, [user, isSuperAdmin]);
// 메인 화면 복사 정보
const [screenName, setScreenName] = useState(""); const [screenName, setScreenName] = useState("");
const [screenCode, setScreenCode] = useState(""); const [screenCode, setScreenCode] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
// 대상 회사 선택 (최고 관리자 전용)
const [targetCompanyCode, setTargetCompanyCode] = useState<string>("");
const [companies, setCompanies] = useState<CompanyInfo[]>([]);
const [loadingCompanies, setLoadingCompanies] = useState(false);
// 연결된 모달 화면들
const [linkedScreens, setLinkedScreens] = useState<LinkedModalScreen[]>([]);
const [loadingLinkedScreens, setLoadingLinkedScreens] = useState(false);
// 화면명 일괄 수정 기능
const [useBulkRename, setUseBulkRename] = useState(false);
const [removeText, setRemoveText] = useState("");
const [addPrefix, setAddPrefix] = useState("");
// 복사 중 상태
const [isCopying, setIsCopying] = useState(false); const [isCopying, setIsCopying] = useState(false);
// 모달이 열릴 때 초기값 설정 // 최고 관리자인 경우 회사 목록 조회
useEffect(() => { useEffect(() => {
console.log("🔍 회사 목록 조회 체크:", { isSuperAdmin, isOpen });
if (isSuperAdmin && isOpen) {
console.log("✅ 회사 목록 조회 시작");
loadCompanies();
}
}, [isSuperAdmin, isOpen]);
// 모달이 열릴 때 초기값 설정 및 연결된 화면 감지
useEffect(() => {
console.log("🔍 모달 초기화:", { isOpen, sourceScreen, isSuperAdmin });
if (isOpen && sourceScreen) { if (isOpen && sourceScreen) {
// 메인 화면 정보 설정
setScreenName(`${sourceScreen.screenName} (복사본)`); setScreenName(`${sourceScreen.screenName} (복사본)`);
setDescription(sourceScreen.description || ""); setDescription(sourceScreen.description || "");
// 화면 코드 자동 생성
generateNewScreenCode();
}
}, [isOpen, sourceScreen]);
// 새로운 화면 코드 자동 생성 // 대상 회사 코드 설정
const generateNewScreenCode = async () => { if (isSuperAdmin) {
if (!sourceScreen?.companyCode) return; setTargetCompanyCode(sourceScreen.companyCode); // 기본값: 원본과 같은 회사
} else {
setTargetCompanyCode(sourceScreen.companyCode);
}
// 연결된 모달 화면 감지
console.log("✅ 연결된 모달 화면 감지 시작");
detectLinkedModals();
}
}, [isOpen, sourceScreen, isSuperAdmin]);
// 일괄 변경 설정이 변경될 때 화면명 자동 업데이트
useEffect(() => {
if (!sourceScreen) return;
if (useBulkRename) {
// 일괄 수정 사용 시: (복사본) 텍스트 제거
const newMainName = applyBulkRename(sourceScreen.screenName);
setScreenName(newMainName);
// 모달 화면명 업데이트
setLinkedScreens((prev) =>
prev.map((screen) => ({
...screen,
newScreenName: applyBulkRename(screen.screenName),
}))
);
} else {
// 일괄 수정 미사용 시: (복사본) 텍스트 추가
setScreenName(`${sourceScreen.screenName} (복사본)`);
setLinkedScreens((prev) =>
prev.map((screen) => ({
...screen,
newScreenName: screen.screenName,
}))
);
}
}, [useBulkRename, removeText, addPrefix]);
// 대상 회사 변경 시 기존 코드 초기화
useEffect(() => {
if (targetCompanyCode) {
console.log("🔄 회사 변경 → 기존 코드 초기화:", targetCompanyCode);
setScreenCode("");
// 모달 화면들의 코드도 초기화
setLinkedScreens((prev) =>
prev.map((screen) => ({ ...screen, newScreenCode: undefined }))
);
}
}, [targetCompanyCode]);
// linkedScreens 로딩이 완료되면 화면 코드 생성
useEffect(() => {
console.log("🔍 코드 생성 조건 체크:", {
targetCompanyCode,
loadingLinkedScreens,
screenCode,
linkedScreensCount: linkedScreens.length,
});
if (targetCompanyCode && !loadingLinkedScreens && !screenCode) {
console.log("✅ 화면 코드 생성 시작 (linkedScreens 개수:", linkedScreens.length, ")");
generateScreenCodes();
}
}, [targetCompanyCode, loadingLinkedScreens, screenCode]);
// 회사 목록 조회
const loadCompanies = async () => {
try {
setLoadingCompanies(true);
const response = await apiClient.get("/admin/companies");
const data = response.data.data || response.data || [];
setCompanies(data.map((c: any) => ({
companyCode: c.company_code || c.companyCode,
companyName: c.company_name || c.companyName,
})));
} catch (error) {
console.error("회사 목록 조회 실패:", error);
toast.error("회사 목록을 불러오는데 실패했습니다.");
} finally {
setLoadingCompanies(false);
}
};
// 연결된 모달 화면 감지
const detectLinkedModals = async () => {
if (!sourceScreen) return;
try { try {
const newCode = await screenApi.generateScreenCode(sourceScreen.companyCode); setLoadingLinkedScreens(true);
setScreenCode(newCode); console.log("📡 API 호출: detectLinkedModals", sourceScreen.screenId);
const linked = await screenApi.detectLinkedModals(sourceScreen.screenId);
console.log("✅ 연결된 모달 화면 감지 결과:", linked);
// 초기 newScreenName 설정
setLinkedScreens(
linked.map((screen) => ({
...screen,
newScreenName: `${screen.screenName} (복사본)`,
}))
);
if (linked.length > 0) {
toast.info(`${linked.length}개의 연결된 모달 화면을 감지했습니다.`);
console.log("🎉 감지된 화면들:", linked);
} else {
console.log(" 연결된 모달 화면이 없습니다.");
}
} catch (error) { } catch (error) {
// console.error("화면 코드 생성 실패:", error); console.error("❌ 연결된 화면 감지 실패:", error);
// 에러가 나도 진행 가능하도록 무시
} finally {
setLoadingLinkedScreens(false);
}
};
// 화면 코드 자동 생성 (메인 + 모달 화면들) - 일괄 생성으로 중복 방지
const generateScreenCodes = async () => {
if (!targetCompanyCode) {
console.log("❌ targetCompanyCode가 없어서 화면 코드 생성 중단");
return;
}
try {
// 메인 화면 1개 + 연결된 모달 화면들 = 총 개수
const totalCount = 1 + linkedScreens.length;
console.log(`📡 화면 코드 일괄 생성 API 호출: ${targetCompanyCode}, 개수: ${totalCount}`);
// 한 번에 모든 코드 생성 (중복 방지)
const generatedCodes = await screenApi.generateMultipleScreenCodes(
targetCompanyCode,
totalCount
);
console.log("✅ 생성된 화면 코드들:", generatedCodes);
// 첫 번째 코드는 메인 화면용
setScreenCode(generatedCodes[0]);
console.log("✅ 메인 화면 코드:", generatedCodes[0]);
// 나머지 코드들은 모달 화면들에 순서대로 할당
if (linkedScreens.length > 0) {
const updatedLinkedScreens = linkedScreens.map((screen, index) => ({
...screen,
newScreenCode: generatedCodes[index + 1], // 1번째부터 시작
}));
setLinkedScreens(updatedLinkedScreens);
console.log("✅ 모달 화면 코드 할당 완료:", updatedLinkedScreens.map(s => ({
name: s.screenName,
code: s.newScreenCode
})));
}
} catch (error) {
console.error("❌ 화면 코드 일괄 생성 실패:", error);
toast.error("화면 코드 생성에 실패했습니다."); toast.error("화면 코드 생성에 실패했습니다.");
} }
}; };
// 연결된 화면 이름 변경
const updateLinkedScreenName = (screenId: number, newName: string) => {
setLinkedScreens((prev) =>
prev.map((screen) =>
screen.screenId === screenId ? { ...screen, newScreenName: newName } : screen
)
);
};
// 연결된 화면 제거 (복사하지 않음)
const removeLinkedScreen = (screenId: number) => {
setLinkedScreens((prev) => prev.filter((screen) => screen.screenId !== screenId));
};
// 화면명 일괄 변경 적용
const applyBulkRename = (originalName: string): string => {
if (!useBulkRename) return originalName;
let newName = originalName;
// 1. 제거할 텍스트 제거
if (removeText.trim()) {
newName = newName.replace(new RegExp(removeText.trim(), "g"), "");
newName = newName.trim(); // 앞뒤 공백 제거
}
// 2. 접두사 추가
if (addPrefix.trim()) {
newName = addPrefix.trim() + " " + newName;
}
return newName;
};
// 미리보기: 변경될 화면명들
const getPreviewNames = () => {
if (!sourceScreen || !useBulkRename) return null;
return {
main: {
original: sourceScreen.screenName,
preview: applyBulkRename(sourceScreen.screenName), // (복사본) 없음
},
modals: linkedScreens.map((screen) => ({
original: screen.screenName,
preview: applyBulkRename(screen.screenName),
})),
};
};
// 화면 복사 실행 // 화면 복사 실행
const handleCopy = async () => { const handleCopy = async () => {
if (!sourceScreen) return; if (!sourceScreen) return;
@ -70,21 +336,75 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
return; return;
} }
// 연결된 화면들의 이름 검증
for (const linked of linkedScreens) {
if (!linked.newScreenName?.trim()) {
toast.error(`"${linked.screenName}" 모달 화면의 새 이름을 입력해주세요.`);
return;
}
if (!linked.newScreenCode?.trim()) {
toast.error(`"${linked.screenName}" 모달 화면의 코드가 생성되지 않았습니다.`);
return;
}
}
try { try {
setIsCopying(true); setIsCopying(true);
// 화면 복사 API 호출 // 화면명 중복 체크
await screenApi.copyScreen(sourceScreen.screenId, { const companyCode = targetCompanyCode || sourceScreen.companyCode;
screenName: screenName.trim(),
screenCode: screenCode.trim(), // 메인 화면명 중복 체크
description: description.trim(), const isMainDuplicate = await screenApi.checkDuplicateScreenName(
companyCode,
screenName.trim()
);
if (isMainDuplicate) {
toast.error(`"${screenName}" 화면명이 이미 존재합니다. 다른 이름을 입력해주세요.`);
setIsCopying(false);
return;
}
// 모달 화면명 중복 체크
for (const linked of linkedScreens) {
const isModalDuplicate = await screenApi.checkDuplicateScreenName(
companyCode,
linked.newScreenName!.trim()
);
if (isModalDuplicate) {
toast.error(
`"${linked.newScreenName}" 화면명이 이미 존재합니다. 모달 화면의 이름을 변경해주세요.`
);
setIsCopying(false);
return;
}
}
// 메인 화면 + 모달 화면들 일괄 복사
const result = await screenApi.copyScreenWithModals(sourceScreen.screenId, {
targetCompanyCode: targetCompanyCode || undefined, // 최고 관리자: 대상 회사 전달
mainScreen: {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
description: description.trim(),
},
modalScreens: linkedScreens.map((screen) => ({
sourceScreenId: screen.screenId,
screenName: screen.newScreenName!.trim(),
screenCode: screen.newScreenCode!.trim(),
})),
}); });
toast.success("화면이 성공적으로 복사되었습니다."); console.log("✅ 복사 완료:", result);
toast.success(
`화면 복사가 완료되었습니다! (메인 1개 + 모달 ${result.modalScreens.length}개)`
);
onCopySuccess(); onCopySuccess();
handleClose(); handleClose();
} catch (error: any) { } catch (error: any) {
// console.error("화면 복사 실패:", error); console.error("화면 복사 실패:", error);
const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다."; const errorMessage = error.response?.data?.message || "화면 복사에 실패했습니다.";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -97,19 +417,26 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
setScreenName(""); setScreenName("");
setScreenCode(""); setScreenCode("");
setDescription(""); setDescription("");
setTargetCompanyCode("");
setLinkedScreens([]);
onClose(); onClose();
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Copy className="h-5 w-5" /> <Copy className="h-5 w-5" />
{linkedScreens.length > 0 && (
<span className="text-sm font-normal text-muted-foreground">
({linkedScreens.length} )
</span>
)}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
{sourceScreen?.screenName} . . {sourceScreen?.screenName} . .
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -130,8 +457,116 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
</div> </div>
</div> </div>
{/* 새 화면 정보 입력 */} {/* 최고 관리자: 대상 회사 선택 */}
<div className="space-y-3"> {isSuperAdmin && (
<div>
<Label htmlFor="targetCompany"> * ( )</Label>
<Select value={targetCompanyCode} onValueChange={setTargetCompanyCode}>
<SelectTrigger id="targetCompany" className="mt-1">
<SelectValue placeholder="회사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loadingCompanies ? (
<div className="p-2 text-center text-sm text-muted-foreground">
...
</div>
) : (
companies.map((company) => (
<SelectItem key={company.companyCode} value={company.companyCode}>
{company.companyName} ({company.companyCode})
</SelectItem>
))
)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
. .
</p>
</div>
)}
{/* 화면명 일괄 수정 */}
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="mb-3 flex items-center gap-2">
<input
type="checkbox"
id="useBulkRename"
checked={useBulkRename}
onChange={(e) => setUseBulkRename(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-2 focus:ring-blue-500"
/>
<Label htmlFor="useBulkRename" className="text-sm font-medium text-blue-900 cursor-pointer">
🔄 ()
</Label>
</div>
{useBulkRename && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="removeText" className="text-xs text-blue-900">
</Label>
<Input
id="removeText"
value={removeText}
onChange={(e) => setRemoveText(e.target.value)}
placeholder="예: 탑씰"
className="mt-1 bg-white"
/>
</div>
<div>
<Label htmlFor="addPrefix" className="text-xs text-blue-900">
</Label>
<Input
id="addPrefix"
value={addPrefix}
onChange={(e) => setAddPrefix(e.target.value)}
placeholder="예: 대진산업"
className="mt-1 bg-white"
/>
</div>
</div>
{/* 미리보기 */}
{(removeText || addPrefix) && getPreviewNames() && (
<div className="rounded-md border border-blue-300 bg-white p-3">
<p className="mb-2 text-xs font-medium text-blue-900"></p>
<div className="space-y-2 text-xs">
{/* 메인 화면 */}
<div>
<p className="text-gray-500">
: <span className="line-through">{getPreviewNames()?.main.original}</span>
</p>
<p className="font-medium text-blue-700">
{getPreviewNames()?.main.preview}
</p>
</div>
{/* 모달 화면들 */}
{getPreviewNames()?.modals.map((modal, idx) => (
<div key={idx}>
<p className="text-gray-500">
: <span className="line-through">{modal.original}</span>
</p>
<p className="font-medium text-blue-700"> {modal.preview}</p>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-blue-700">
💡 "제거할 텍스트" "추가할 접두사" .
</p>
</div>
)}
</div>
{/* 메인 화면 정보 입력 */}
<div className="space-y-3 rounded-lg border p-3">
<h4 className="text-sm font-medium"> </h4>
<div> <div>
<Label htmlFor="screenName"> *</Label> <Label htmlFor="screenName"> *</Label>
<Input <Input
@ -166,13 +601,79 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
/> />
</div> </div>
</div> </div>
{/* 연결된 모달 화면 목록 */}
{loadingLinkedScreens ? (
<div className="flex items-center justify-center gap-2 rounded-lg border p-6">
<Loader2 className="h-5 w-5 animate-spin" />
<span className="text-sm text-muted-foreground"> ...</span>
</div>
) : linkedScreens.length > 0 ? (
<div className="space-y-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<h4 className="flex items-center gap-2 text-sm font-medium">
<LinkIcon className="h-4 w-4" />
({linkedScreens.length})
</h4>
<p className="text-xs text-muted-foreground"> </p>
</div>
<div className="space-y-2">
{linkedScreens.map((linkedScreen) => (
<div
key={linkedScreen.screenId}
className="space-y-2 rounded-md border bg-gray-50 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 space-y-1">
<p className="text-xs text-muted-foreground">
: {linkedScreen.screenName} ({linkedScreen.screenCode})
</p>
<Input
value={linkedScreen.newScreenName || ""}
onChange={(e) =>
updateLinkedScreenName(linkedScreen.screenId, e.target.value)
}
placeholder="복사될 모달 화면 이름"
className="h-8 text-sm"
/>
<Input
value={linkedScreen.newScreenCode || ""}
readOnly
className="h-8 bg-white text-xs"
placeholder="코드 자동 생성"
/>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:bg-destructive/10"
onClick={() => removeLinkedScreen(linkedScreen.screenId)}
title="이 화면은 복사하지 않음"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
.
</AlertDescription>
</Alert>
</div>
) : null}
</div> </div>
<DialogFooter> <DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" onClick={handleClose} disabled={isCopying}> <Button variant="outline" onClick={handleClose} disabled={isCopying}>
</Button> </Button>
<Button onClick={handleCopy} disabled={isCopying}> <Button onClick={handleCopy} disabled={isCopying || !targetCompanyCode}>
{isCopying ? ( {isCopying ? (
<> <>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
@ -182,6 +683,7 @@ export default function CopyScreenModal({ isOpen, onClose, sourceScreen, onCopyS
<> <>
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
{linkedScreens.length > 0 && ` (${linkedScreens.length + 1}개 화면)`}
</> </>
)} )}
</Button> </Button>

View File

@ -575,6 +575,8 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
setLoading(true); setLoading(true);
try { try {
console.log("🔍 데이터 조회 시작:", { tableName: component.tableName, page, pageSize });
const result = await tableTypeApi.getTableData(component.tableName, { const result = await tableTypeApi.getTableData(component.tableName, {
page, page,
size: pageSize, size: pageSize,
@ -582,6 +584,13 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달 autoFilter: component.autoFilter, // 🆕 자동 필터 설정 전달
}); });
console.log("✅ 데이터 조회 완료:", {
tableName: component.tableName,
dataLength: result.data.length,
total: result.total,
page: result.page
});
setData(result.data); setData(result.data);
setTotal(result.total); setTotal(result.total);
setTotalPages(result.totalPages); setTotalPages(result.totalPages);
@ -1952,7 +1961,7 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
// 실제 웹 타입으로 스위치 (input_type="category"도 포함됨) // 실제 웹 타입으로 스위치 (input_type="category"도 포함됨)
switch (actualWebType) { switch (actualWebType) {
case "category": { case "category": {
// 카테고리 타입: 배지로 표시 // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (!value) return ""; if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
@ -1962,6 +1971,11 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
const displayLabel = categoryData?.label || String(value); const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return ( return (
<Badge <Badge
style={{ style={{

View File

@ -4210,6 +4210,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
onCopyComponent={copyComponent} onCopyComponent={copyComponent}
currentTable={tables.length > 0 ? tables[0] : undefined} currentTable={tables.length > 0 ? tables[0] : undefined}
currentTableName={selectedScreen?.tableName} currentTableName={selectedScreen?.tableName}
currentScreenCompanyCode={selectedScreen?.companyCode}
dragState={dragState} dragState={dragState}
onStyleChange={(style) => { onStyleChange={(style) => {
if (selectedComponent) { if (selectedComponent) {

View File

@ -21,6 +21,7 @@ interface ButtonConfigPanelProps {
onUpdateProperty: (path: string, value: any) => void; onUpdateProperty: (path: string, value: any) => void;
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용 allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
} }
interface ScreenOption { interface ScreenOption {
@ -34,6 +35,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
onUpdateProperty, onUpdateProperty,
allComponents = [], // 🆕 기본값 빈 배열 allComponents = [], // 🆕 기본값 빈 배열
currentTableName, // 현재 화면의 테이블명 currentTableName, // 현재 화면의 테이블명
currentScreenCompanyCode, // 현재 편집 중인 화면의 회사 코드
}) => { }) => {
// 🔧 component에서 직접 읽기 (useMemo 제거) // 🔧 component에서 직접 읽기 (useMemo 제거)
const config = component.componentConfig || {}; const config = component.componentConfig || {};
@ -96,17 +98,24 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [component.id]); }, [component.id]);
// 화면 목록 가져오기 (전체 목록) // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => { useEffect(() => {
const fetchScreens = async () => { const fetchScreens = async () => {
try { try {
setScreensLoading(true); setScreensLoading(true);
// 전체 목록을 가져오기 위해 size를 큰 값으로 설정 // 현재 편집 중인 화면의 회사 코드 기준으로 화면 목록 조회
const params: any = {
page: 1,
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
};
// 현재 화면의 회사 코드가 있으면 필터링 파라미터로 전달
if (currentScreenCompanyCode) {
params.companyCode = currentScreenCompanyCode;
}
const response = await apiClient.get("/screen-management/screens", { const response = await apiClient.get("/screen-management/screens", {
params: { params,
page: 1,
size: 9999, // 매우 큰 값으로 설정하여 전체 목록 가져오기
},
}); });
if (response.data.success && Array.isArray(response.data.data)) { if (response.data.success && Array.isArray(response.data.data)) {
@ -125,7 +134,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
}; };
fetchScreens(); fetchScreens();
}, []); }, [currentScreenCompanyCode]);
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때) // 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
useEffect(() => { useEffect(() => {
@ -343,7 +352,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
disabled={screensLoading} disabled={screensLoading}
> >
{config.action?.targetScreenId {config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name || ? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..." "화면을 선택하세요..."
: "화면을 선택하세요..."} : "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -382,7 +391,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0", parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
@ -418,7 +427,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
disabled={screensLoading} disabled={screensLoading}
> >
{config.action?.targetScreenId {config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name || ? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"수정 폼 화면을 선택하세요..." "수정 폼 화면을 선택하세요..."
: "수정 폼 화면을 선택하세요..."} : "수정 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -457,7 +466,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0", parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
@ -572,7 +581,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
disabled={screensLoading} disabled={screensLoading}
> >
{config.action?.targetScreenId {config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name || ? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"복사 폼 화면을 선택하세요..." "복사 폼 화면을 선택하세요..."
: "복사 폼 화면을 선택하세요..."} : "복사 폼 화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -611,7 +620,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0", parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
@ -790,7 +799,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
disabled={screensLoading} disabled={screensLoading}
> >
{config.action?.targetScreenId {config.action?.targetScreenId
? screens.find((screen) => screen.id === config.action?.targetScreenId)?.name || ? screens.find((screen) => screen.id === parseInt(config.action?.targetScreenId))?.name ||
"화면을 선택하세요..." "화면을 선택하세요..."
: "화면을 선택하세요..."} : "화면을 선택하세요..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
@ -829,7 +838,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
config.action?.targetScreenId === screen.id ? "opacity-100" : "opacity-0", parseInt(config.action?.targetScreenId) === screen.id ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">

View File

@ -0,0 +1,374 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management";
import { apiClient } from "@/lib/api/client";
interface DataFilterConfigPanelProps {
tableName?: string;
columns?: UnifiedColumnInfo[];
config?: DataFilterConfig;
onConfigChange: (config: DataFilterConfig) => void;
}
/**
*
* , ,
*/
export function DataFilterConfigPanel({
tableName,
columns = [],
config,
onConfigChange,
}: DataFilterConfigPanelProps) {
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
config || {
enabled: false,
filters: [],
matchType: "all",
}
);
// 카테고리 값 캐시 (컬럼명 -> 카테고리 값 목록)
const [categoryValues, setCategoryValues] = useState<Record<string, Array<{ value: string; label: string }>>>({});
const [loadingCategories, setLoadingCategories] = useState<Record<string, boolean>>({});
useEffect(() => {
if (config) {
setLocalConfig(config);
}
}, [config]);
// 카테고리 값 로드
const loadCategoryValues = async (columnName: string) => {
if (!tableName || categoryValues[columnName] || loadingCategories[columnName]) {
return; // 이미 로드되었거나 로딩 중이면 스킵
}
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
try {
const response = await apiClient.get(
`/table-categories/${tableName}/${columnName}/values`
);
if (response.data.success && response.data.data) {
const values = response.data.data.map((item: any) => ({
value: item.valueCode,
label: item.valueLabel,
}));
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
}
} catch (error) {
console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
}
};
const handleEnabledChange = (enabled: boolean) => {
const newConfig = { ...localConfig, enabled };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const handleMatchTypeChange = (matchType: "all" | "any") => {
const newConfig = { ...localConfig, matchType };
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const handleAddFilter = () => {
const newFilter: ColumnFilter = {
id: `filter-${Date.now()}`,
columnName: columns[0]?.columnName || "",
operator: "equals",
value: "",
valueType: "static",
};
const newConfig = {
...localConfig,
filters: [...localConfig.filters, newFilter],
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const handleRemoveFilter = (filterId: string) => {
const newConfig = {
...localConfig,
filters: localConfig.filters.filter((f) => f.id !== filterId),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
const handleFilterChange = (filterId: string, field: keyof ColumnFilter, value: any) => {
const newConfig = {
...localConfig,
filters: localConfig.filters.map((filter) =>
filter.id === filterId ? { ...filter, [field]: value } : filter
),
};
setLocalConfig(newConfig);
onConfigChange(newConfig);
};
// 선택된 컬럼의 input_type 찾기 (데이터베이스의 실제 input_type)
const getColumnInputType = (columnName: string) => {
const column = columns.find((col) => col.columnName === columnName);
// input_type (소문자) 필드 사용 - 이것이 실제 카테고리/엔티티 타입 정보
return column?.input_type || column?.webType || "text";
};
// 카테고리/코드 타입인지 확인
const isCategoryOrCodeColumn = (columnName: string) => {
const inputType = getColumnInputType(columnName);
return inputType === "category" || inputType === "code";
};
return (
<div className="space-y-4">
{/* 필터 활성화 */}
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch checked={localConfig.enabled} onCheckedChange={handleEnabledChange} />
</div>
{localConfig.enabled && (
<>
{/* 테이블명 표시 */}
{tableName && (
<div className="text-xs text-muted-foreground">
: <span className="font-medium">{tableName}</span>
</div>
)}
{/* 매칭 타입 */}
{localConfig.filters.length > 1 && (
<div>
<Label className="text-xs sm:text-sm"> </Label>
<Select value={localConfig.matchType} onValueChange={handleMatchTypeChange}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (AND)</SelectItem>
<SelectItem value="any"> (OR)</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 필터 목록 */}
<div className="space-y-3 max-h-[600px] overflow-y-auto pr-2">
{localConfig.filters.map((filter, index) => (
<div key={filter.id} className="rounded-lg border p-3 space-y-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground">
{index + 1}
</span>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleRemoveFilter(filter.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.columnName}
onValueChange={(value) => {
const column = columns.find((col) => col.columnName === value);
console.log("🔍 컬럼 선택:", {
columnName: value,
input_type: column?.input_type,
column,
});
// 컬럼 타입에 따라 valueType 자동 설정
let valueType: "static" | "category" | "code" = "static";
if (column?.input_type === "category") {
valueType = "category";
console.log("📦 카테고리 컬럼 감지, 값 로딩 시작:", value);
loadCategoryValues(value); // 카테고리 값 로드
} else if (column?.input_type === "code") {
valueType = "code";
}
// 한 번에 모든 변경사항 적용
const newConfig = {
...localConfig,
filters: localConfig.filters.map((f) =>
f.id === filter.id
? { ...f, columnName: value, valueType, value: "" }
: f
),
};
console.log("✅ 필터 설정 업데이트:", {
filterId: filter.id,
columnName: value,
valueType,
newConfig,
});
setLocalConfig(newConfig);
onConfigChange(newConfig);
}}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
{(col.input_type === "category" || col.input_type === "code") && (
<span className="ml-2 text-xs text-muted-foreground">
({col.input_type})
</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={filter.operator}
onValueChange={(value: any) => handleFilterChange(filter.id, "operator", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals"> (=)</SelectItem>
<SelectItem value="not_equals"> ()</SelectItem>
<SelectItem value="in"> (IN)</SelectItem>
<SelectItem value="not_in"> (NOT IN)</SelectItem>
<SelectItem value="contains"> (LIKE %value%)</SelectItem>
<SelectItem value="starts_with"> (LIKE value%)</SelectItem>
<SelectItem value="ends_with"> (LIKE %value)</SelectItem>
<SelectItem value="is_null">NULL</SelectItem>
<SelectItem value="is_not_null">NOT NULL</SelectItem>
</SelectContent>
</Select>
</div>
{/* 값 타입 선택 (카테고리/코드 컬럼만) */}
{isCategoryOrCodeColumn(filter.columnName) && (
<div>
<Label className="text-xs"> </Label>
<Select
value={filter.valueType}
onValueChange={(value: any) =>
handleFilterChange(filter.id, "valueType", value)
}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"> </SelectItem>
<SelectItem value="category"> </SelectItem>
<SelectItem value="code"> </SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* 값 입력 (NULL 체크 제외) */}
{filter.operator !== "is_null" && filter.operator !== "is_not_null" && (
<div>
<Label className="text-xs"></Label>
{/* 카테고리 타입이고 값 타입이 category인 경우 셀렉트박스 */}
{filter.valueType === "category" && categoryValues[filter.columnName] ? (
<Select
value={Array.isArray(filter.value) ? filter.value[0] : filter.value}
onValueChange={(value) => handleFilterChange(filter.id, "value", value)}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder={
loadingCategories[filter.columnName] ? "로딩 중..." : "값 선택"
} />
</SelectTrigger>
<SelectContent>
{categoryValues[filter.columnName].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : filter.operator === "in" || filter.operator === "not_in" ? (
<Input
value={Array.isArray(filter.value) ? filter.value.join(", ") : filter.value}
onChange={(e) => {
const values = e.target.value.split(",").map((v) => v.trim());
handleFilterChange(filter.id, "value", values);
}}
placeholder="쉼표로 구분 (예: 값1, 값2, 값3)"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
) : (
<Input
value={Array.isArray(filter.value) ? filter.value[0] || "" : filter.value}
onChange={(e) => handleFilterChange(filter.id, "value", e.target.value)}
placeholder="필터 값 입력"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
)}
<p className="text-[10px] text-muted-foreground mt-1">
{filter.valueType === "category" && categoryValues[filter.columnName]
? "카테고리 값을 선택하세요"
: filter.operator === "in" || filter.operator === "not_in"
? "여러 값은 쉼표(,)로 구분하세요"
: "필터링할 값을 입력하세요"}
</p>
</div>
)}
</div>
))}
</div>
{/* 필터 추가 버튼 */}
<Button
variant="outline"
size="sm"
className="w-full h-8 text-xs sm:h-10 sm:text-sm"
onClick={handleAddFilter}
disabled={columns.length === 0}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
{columns.length === 0 && (
<p className="text-xs text-muted-foreground text-center">
</p>
)}
</>
)}
</div>
);
}

View File

@ -11,6 +11,7 @@ import { getFlowDefinitions } from "@/lib/api/flow";
import type { FlowDefinition } from "@/types/flow"; import type { FlowDefinition } from "@/types/flow";
import { Loader2, Check, ChevronsUpDown } from "lucide-react"; import { Loader2, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
interface FlowWidgetConfigPanelProps { interface FlowWidgetConfigPanelProps {
config: Record<string, any>; config: Record<string, any>;
@ -153,6 +154,24 @@ export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfi
</div> </div>
</div> </div>
</div> </div>
{/* 🆕 데이터 필터링 설정 */}
{config.flowId && (
<div>
<div className="mb-2">
<h3 className="text-sm font-medium"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<DataFilterConfigPanel
tableName={selectedFlow?.name}
columns={[]} // 플로우의 첫 번째 스텝 테이블 컬럼 정보 필요 (TODO: API 연동)
config={config.dataFilter}
onConfigChange={(dataFilter) => onChange({ ...config, dataFilter })}
/>
</div>
)}
</div> </div>
); );
} }

View File

@ -42,6 +42,7 @@ interface DetailSettingsPanelProps {
currentTable?: TableInfo; // 현재 화면의 테이블 정보 currentTable?: TableInfo; // 현재 화면의 테이블 정보
currentTableName?: string; // 현재 화면의 테이블명 currentTableName?: string; // 현재 화면의 테이블명
tables?: TableInfo[]; // 전체 테이블 목록 tables?: TableInfo[]; // 전체 테이블 목록
currentScreenCompanyCode?: string; // 현재 편집 중인 화면의 회사 코드
} }
export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
@ -50,6 +51,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
currentTable, currentTable,
currentTableName, currentTableName,
tables = [], // 기본값 빈 배열 tables = [], // 기본값 빈 배열
currentScreenCompanyCode,
}) => { }) => {
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기 // 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });
@ -868,6 +870,7 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
component={selectedComponent} component={selectedComponent}
onUpdateProperty={handleUpdateProperty} onUpdateProperty={handleUpdateProperty}
currentTableName={currentTableName} currentTableName={currentTableName}
currentScreenCompanyCode={currentScreenCompanyCode}
/> />
); );

View File

@ -86,6 +86,8 @@ interface UnifiedPropertiesPanelProps {
allComponents?: ComponentData[]; allComponents?: ComponentData[];
// 🆕 메뉴 OBJID (코드/카테고리 스코프용) // 🆕 메뉴 OBJID (코드/카테고리 스코프용)
menuObjid?: number; menuObjid?: number;
// 🆕 현재 편집 중인 화면의 회사 코드
currentScreenCompanyCode?: string;
} }
export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
@ -98,6 +100,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
onCopyComponent, onCopyComponent,
currentTable, currentTable,
currentTableName, currentTableName,
currentScreenCompanyCode,
dragState, dragState,
onStyleChange, onStyleChange,
menuObjid, menuObjid,
@ -278,6 +281,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
onUpdateProperty={handleUpdateProperty} onUpdateProperty={handleUpdateProperty}
allComponents={allComponents} allComponents={allComponents}
currentTableName={currentTableName} currentTableName={currentTableName}
currentScreenCompanyCode={currentScreenCompanyCode}
/> />
); );

View File

@ -0,0 +1,303 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus } from "lucide-react";
import { toast } from "sonner";
import { createColumnMapping } from "@/lib/api/tableCategoryValue";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { apiClient } from "@/lib/api/client";
interface SecondLevelMenu {
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}
interface AddCategoryColumnDialogProps {
tableName: string;
onSuccess: () => void;
}
/**
*
*
*
*
* 2
*/
export function AddCategoryColumnDialog({
tableName,
onSuccess,
}: AddCategoryColumnDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [physicalColumns, setPhysicalColumns] = useState<string[]>([]);
const [secondLevelMenus, setSecondLevelMenus] = useState<SecondLevelMenu[]>([]);
const [selectedMenus, setSelectedMenus] = useState<number[]>([]);
const [logicalColumnName, setLogicalColumnName] = useState("");
const [physicalColumnName, setPhysicalColumnName] = useState("");
const [description, setDescription] = useState("");
// 다이얼로그 열릴 때 데이터 로드
useEffect(() => {
if (open) {
loadPhysicalColumns();
loadSecondLevelMenus();
}
}, [open, tableName]);
// 테이블의 실제 컬럼 목록 조회
const loadPhysicalColumns = async () => {
try {
const response = await tableManagementApi.getTableColumns(tableName);
if (response.success && response.data) {
setPhysicalColumns(response.data.map((col: any) => col.columnName));
}
} catch (error) {
console.error("컬럼 목록 조회 실패:", error);
toast.error("컬럼 목록을 불러올 수 없습니다");
}
};
// 2레벨 메뉴 목록 조회
const loadSecondLevelMenus = async () => {
try {
const response = await apiClient.get<{
success: boolean;
data: SecondLevelMenu[];
}>("table-categories/second-level-menus");
if (response.data.success && response.data.data) {
setSecondLevelMenus(response.data.data);
}
} catch (error) {
console.error("2레벨 메뉴 목록 조회 실패:", error);
toast.error("메뉴 목록을 불러올 수 없습니다");
}
};
// 메뉴 선택/해제
const toggleMenu = (menuObjid: number) => {
setSelectedMenus((prev) =>
prev.includes(menuObjid)
? prev.filter((id) => id !== menuObjid)
: [...prev, menuObjid]
);
};
const handleSave = async () => {
// 입력 검증
if (!logicalColumnName.trim()) {
toast.error("논리적 컬럼명을 입력해주세요");
return;
}
if (!physicalColumnName) {
toast.error("실제 컬럼을 선택해주세요");
return;
}
if (selectedMenus.length === 0) {
toast.error("최소 하나 이상의 메뉴를 선택해주세요");
return;
}
setLoading(true);
try {
// 선택된 각 메뉴에 대해 매핑 생성
const promises = selectedMenus.map((menuObjid) =>
createColumnMapping({
tableName,
logicalColumnName: logicalColumnName.trim(),
physicalColumnName,
menuObjid,
description: description.trim() || undefined,
})
);
const results = await Promise.all(promises);
// 모든 요청이 성공했는지 확인
const failedCount = results.filter((r) => !r.success).length;
if (failedCount === 0) {
toast.success(`논리적 컬럼이 ${selectedMenus.length}개 메뉴에 추가되었습니다`);
setOpen(false);
resetForm();
onSuccess();
} else if (failedCount < results.length) {
toast.warning(
`${results.length - failedCount}개 메뉴에 추가 성공, ${failedCount}개 실패`
);
onSuccess();
} else {
toast.error("모든 메뉴에 대한 매핑 생성에 실패했습니다");
}
} catch (error: any) {
console.error("컬럼 매핑 생성 실패:", error);
toast.error(error.message || "컬럼 매핑 생성 중 오류가 발생했습니다");
} finally {
setLoading(false);
}
};
const resetForm = () => {
setLogicalColumnName("");
setPhysicalColumnName("");
setDescription("");
setSelectedMenus([]);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
2
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
{/* 실제 컬럼 선택 */}
<div>
<Label className="text-xs sm:text-sm">
() *
</Label>
<Select value={physicalColumnName} onValueChange={setPhysicalColumnName}>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{physicalColumns.map((col) => (
<SelectItem key={col} value={col} className="text-xs sm:text-sm">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 논리적 컬럼명 입력 */}
<div>
<Label className="text-xs sm:text-sm">
( ) *
</Label>
<Input
value={logicalColumnName}
onChange={(e) => setLogicalColumnName(e.target.value)}
placeholder="예: status_stock, status_sales"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
{/* 적용할 2레벨 메뉴 선택 (체크박스) */}
<div>
<Label className="text-xs sm:text-sm">
(2) *
</Label>
<div className="border rounded-lg p-3 sm:p-4 space-y-2 max-h-48 overflow-y-auto mt-2">
{secondLevelMenus.length === 0 ? (
<p className="text-xs text-muted-foreground"> ...</p>
) : (
secondLevelMenus.map((menu) => (
<div key={menu.menuObjid} className="flex items-center gap-2">
<Checkbox
id={`menu-${menu.menuObjid}`}
checked={selectedMenus.includes(menu.menuObjid)}
onCheckedChange={() => toggleMenu(menu.menuObjid)}
className="h-4 w-4"
/>
<label
htmlFor={`menu-${menu.menuObjid}`}
className="text-xs sm:text-sm cursor-pointer flex-1"
>
{menu.parentMenuName} {menu.menuName}
</label>
</div>
))
)}
</div>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
{selectedMenus.length > 0 && (
<p className="text-primary mt-1 text-[10px] sm:text-xs">
{selectedMenus.length}
</p>
)}
</div>
{/* 설명 (선택사항) */}
<div>
<Label className="text-xs sm:text-sm"></Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="이 컬럼의 용도를 설명하세요 (선택사항)"
className="text-xs sm:text-sm"
rows={2}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setOpen(false)}
disabled={loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
onClick={handleSave}
disabled={!logicalColumnName || !physicalColumnName || selectedMenus.length === 0 || loading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{loading ? "추가 중..." : "추가"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -50,7 +50,7 @@ export const CategoryValueAddDialog: React.FC<
> = ({ open, onOpenChange, onAdd, columnLabel }) => { > = ({ open, onOpenChange, onAdd, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(""); const [valueLabel, setValueLabel] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [color, setColor] = useState("#3b82f6"); const [color, setColor] = useState("none");
// 라벨에서 코드 자동 생성 // 라벨에서 코드 자동 생성
const generateCode = (label: string): string => { const generateCode = (label: string): string => {
@ -91,7 +91,7 @@ export const CategoryValueAddDialog: React.FC<
// 초기화 // 초기화
setValueLabel(""); setValueLabel("");
setDescription(""); setDescription("");
setColor("#3b82f6"); setColor("none");
}; };
return ( return (
@ -123,24 +123,41 @@ export const CategoryValueAddDialog: React.FC<
<div> <div>
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3"> <div className="mt-1.5 space-y-2">
<div className="grid grid-cols-9 gap-2"> <div className="flex items-center gap-3">
{DEFAULT_COLORS.map((c) => ( <div className="grid grid-cols-9 gap-2">
<button {DEFAULT_COLORS.map((c) => (
key={c} <button
type="button" key={c}
onClick={() => setColor(c)} type="button"
className={`h-7 w-7 rounded-md border-2 transition-all ${ onClick={() => setColor(c)}
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105" className={`h-7 w-7 rounded-md border-2 transition-all ${
}`} color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
style={{ backgroundColor: c }} }`}
title={c} style={{ backgroundColor: c }}
/> title={c}
))} />
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div> </div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white"> <button
type="button"
</Badge> onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div> </div>
</div> </div>

View File

@ -51,12 +51,12 @@ export const CategoryValueEditDialog: React.FC<
> = ({ open, onOpenChange, value, onUpdate, columnLabel }) => { > = ({ open, onOpenChange, value, onUpdate, columnLabel }) => {
const [valueLabel, setValueLabel] = useState(value.valueLabel); const [valueLabel, setValueLabel] = useState(value.valueLabel);
const [description, setDescription] = useState(value.description || ""); const [description, setDescription] = useState(value.description || "");
const [color, setColor] = useState(value.color || "#3b82f6"); const [color, setColor] = useState(value.color || "none");
useEffect(() => { useEffect(() => {
setValueLabel(value.valueLabel); setValueLabel(value.valueLabel);
setDescription(value.description || ""); setDescription(value.description || "");
setColor(value.color || "#3b82f6"); setColor(value.color || "none");
}, [value]); }, [value]);
const handleSubmit = () => { const handleSubmit = () => {
@ -100,24 +100,41 @@ export const CategoryValueEditDialog: React.FC<
<div> <div>
<Label className="text-xs sm:text-sm"> </Label> <Label className="text-xs sm:text-sm"> </Label>
<div className="mt-1.5 flex items-center gap-3"> <div className="mt-1.5 space-y-2">
<div className="grid grid-cols-9 gap-2"> <div className="flex items-center gap-3">
{DEFAULT_COLORS.map((c) => ( <div className="grid grid-cols-9 gap-2">
<button {DEFAULT_COLORS.map((c) => (
key={c} <button
type="button" key={c}
onClick={() => setColor(c)} type="button"
className={`h-7 w-7 rounded-md border-2 transition-all ${ onClick={() => setColor(c)}
color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105" className={`h-7 w-7 rounded-md border-2 transition-all ${
}`} color === c ? "border-foreground scale-110" : "border-transparent hover:scale-105"
style={{ backgroundColor: c }} }`}
title={c} style={{ backgroundColor: c }}
/> title={c}
))} />
))}
</div>
{color && color !== "none" ? (
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white">
</Badge>
) : (
<span className="text-xs text-muted-foreground"> </span>
)}
</div> </div>
<Badge style={{ backgroundColor: color, borderColor: color }} className="text-white"> <button
type="button"
</Badge> onClick={() => setColor("none")}
className={`text-xs px-3 py-1.5 rounded-md border transition-colors ${
color === "none"
? "border-primary bg-primary/10 text-primary font-medium"
: "border-border hover:bg-accent"
}`}
>
</button>
</div> </div>
</div> </div>

View File

@ -349,13 +349,18 @@ export const CategoryValueManager: React.FC<CategoryValueManagerProps> = ({
/> />
<div className="flex flex-1 items-center gap-2"> <div className="flex flex-1 items-center gap-2">
{/* 색상 표시 (앞쪽으로 이동) */} {/* 색상 표시 (배지 없음 옵션 지원) */}
{value.color && ( {value.color && value.color !== "none" && (
<div <div
className="h-4 w-4 rounded-full border flex-shrink-0" className="h-4 w-4 rounded-full border flex-shrink-0"
style={{ backgroundColor: value.color }} style={{ backgroundColor: value.color }}
/> />
)} )}
{value.color === "none" && (
<span className="text-[10px] text-muted-foreground px-1.5 py-0.5 bg-muted rounded">
</span>
)}
{/* 라벨 */} {/* 라벨 */}
<span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}> <span className={`text-sm font-medium ${isInactive ? "line-through" : ""}`}>

View File

@ -62,6 +62,7 @@ export const dataApi = {
leftColumn: string, leftColumn: string,
rightColumn: string, rightColumn: string,
leftValue?: any, leftValue?: any,
dataFilter?: any, // 🆕 데이터 필터
): Promise<any[]> => { ): Promise<any[]> => {
const response = await apiClient.get(`/data/join`, { const response = await apiClient.get(`/data/join`, {
params: { params: {
@ -70,6 +71,7 @@ export const dataApi = {
leftColumn, leftColumn,
rightColumn, rightColumn,
leftValue, leftValue,
dataFilter: dataFilter ? JSON.stringify(dataFilter) : undefined, // 🆕 데이터 필터 전달
}, },
}); });
const raw = response.data || {}; const raw = response.data || {};

View File

@ -68,6 +68,7 @@ export const entityJoinApi = {
joinAlias: string; joinAlias: string;
}>; }>;
screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정 screenEntityConfigs?: Record<string, any>; // 🎯 화면별 엔티티 설정
dataFilter?: any; // 🆕 데이터 필터
} = {}, } = {},
): Promise<EntityJoinResponse> => { ): Promise<EntityJoinResponse> => {
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@ -103,6 +104,7 @@ export const entityJoinApi = {
additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined, additionalJoinColumns: params.additionalJoinColumns ? JSON.stringify(params.additionalJoinColumns) : undefined,
screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정 screenEntityConfigs: params.screenEntityConfigs ? JSON.stringify(params.screenEntityConfigs) : undefined, // 🎯 화면별 엔티티 설정
autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링 autoFilter: JSON.stringify(autoFilter), // 🔒 멀티테넌시 필터링
dataFilter: params.dataFilter ? JSON.stringify(params.dataFilter) : undefined, // 🆕 데이터 필터
}, },
}); });
return response.data.data; return response.data.data;

View File

@ -53,6 +53,7 @@ export interface MenuFormData {
status: string; status: string;
companyCode: string; companyCode: string;
langKey?: string; // 다국어 키 추가 langKey?: string; // 다국어 키 추가
screenCode?: string; // 화면 코드 추가
} }
export interface LangKey { export interface LangKey {

View File

@ -64,6 +64,15 @@ export const screenApi = {
return response.data.data.screenCode; return response.data.data.screenCode;
}, },
// 여러 개의 화면 코드 일괄 생성
generateMultipleScreenCodes: async (companyCode: string, count: number): Promise<string[]> => {
const response = await apiClient.post("/screen-management/generate-screen-codes", {
companyCode,
count,
});
return response.data.data.screenCodes;
},
// 화면 수정 // 화면 수정
updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => { updateScreen: async (screenId: number, screenData: UpdateScreenRequest): Promise<ScreenDefinition> => {
const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData); const response = await apiClient.put(`/screen-management/screens/${screenId}`, screenData);
@ -169,6 +178,23 @@ export const screenApi = {
return response.data.data; return response.data.data;
}, },
// 연결된 모달 화면 감지
detectLinkedModals: async (
screenId: number,
): Promise<Array<{ screenId: number; screenName: string; screenCode: string }>> => {
const response = await apiClient.get(`/screen-management/screens/${screenId}/linked-modals`);
return response.data.data || [];
},
// 화면명 중복 체크
checkDuplicateScreenName: async (companyCode: string, screenName: string): Promise<boolean> => {
const response = await apiClient.post("/screen-management/screens/check-duplicate-name", {
companyCode,
screenName,
});
return response.data.data.isDuplicate || false;
},
// 화면 복사 (화면정보 + 레이아웃 모두 복사) // 화면 복사 (화면정보 + 레이아웃 모두 복사)
copyScreen: async ( copyScreen: async (
sourceScreenId: number, sourceScreenId: number,
@ -181,6 +207,30 @@ export const screenApi = {
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData); const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy`, copyData);
return response.data.data; return response.data.data;
}, },
// 메인 화면 + 모달 화면들 일괄 복사
copyScreenWithModals: async (
sourceScreenId: number,
copyData: {
targetCompanyCode?: string; // 최고 관리자 전용: 다른 회사로 복사
mainScreen: {
screenName: string;
screenCode: string;
description?: string;
};
modalScreens: Array<{
sourceScreenId: number;
screenName: string;
screenCode: string;
}>;
},
): Promise<{
mainScreen: ScreenDefinition;
modalScreens: ScreenDefinition[];
}> => {
const response = await apiClient.post(`/screen-management/screens/${sourceScreenId}/copy-with-modals`, copyData);
return response.data.data;
},
}; };
// 템플릿 관련 API // 템플릿 관련 API

View File

@ -145,3 +145,120 @@ export async function reorderCategoryValues(orderedValueIds: number[]) {
} }
} }
// ================================================
// 컬럼 매핑 관련 API (논리명 ↔ 물리명)
// ================================================
/**
*
*
* @param tableName -
* @param menuObjid - OBJID
* @returns { logical_column: physical_column }
*/
export async function getColumnMapping(tableName: string, menuObjid: number) {
try {
const response = await apiClient.get<{
success: boolean;
data: Record<string, string>;
}>(`/table-categories/column-mapping/${tableName}/${menuObjid}`);
return response.data;
} catch (error: any) {
console.error("컬럼 매핑 조회 실패:", error);
return { success: false, error: error.message, data: {} };
}
}
/**
*
*
* @param tableName -
* @param menuObjid - OBJID
* @returns
*/
export async function getLogicalColumns(tableName: string, menuObjid: number) {
try {
const response = await apiClient.get<{
success: boolean;
data: Array<{
mappingId: number;
logicalColumnName: string;
physicalColumnName: string;
description?: string;
}>;
}>(`/table-categories/logical-columns/${tableName}/${menuObjid}`);
return response.data;
} catch (error: any) {
console.error("논리적 컬럼 목록 조회 실패:", error);
return { success: false, error: error.message, data: [] };
}
}
/**
* /
*
* @param data -
*/
export async function createColumnMapping(data: {
tableName: string;
logicalColumnName: string;
physicalColumnName: string;
menuObjid: number;
description?: string;
}) {
try {
const response = await apiClient.post<{
success: boolean;
data: any;
message: string;
}>("/table-categories/column-mapping", data);
return response.data;
} catch (error: any) {
console.error("컬럼 매핑 생성 실패:", error);
return { success: false, error: error.message };
}
}
/**
*
*
* @param mappingId - ID
*/
export async function deleteColumnMapping(mappingId: number) {
try {
const response = await apiClient.delete<{
success: boolean;
message: string;
}>(`/table-categories/column-mapping/${mappingId}`);
return response.data;
} catch (error: any) {
console.error("컬럼 매핑 삭제 실패:", error);
return { success: false, error: error.message };
}
}
/**
* 2
*
*
*
* @returns 2
*/
export async function getSecondLevelMenus() {
try {
const response = await apiClient.get<{
success: boolean;
data: Array<{
menuObjid: number;
menuName: string;
parentMenuName: string;
screenCode?: string;
}>;
}>("/table-categories/second-level-menus");
return response.data;
} catch (error: any) {
console.error("2레벨 메뉴 목록 조회 실패:", error);
return { success: false, error: error.message, data: [] };
}
}

View File

@ -257,6 +257,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
size: 100, size: 100,
search: filters, // 필터 조건 전달 search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화 enableEntityJoin: true, // 엔티티 조인 활성화
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
}); });
@ -314,6 +315,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
leftColumn, leftColumn,
rightColumn, rightColumn,
leftValue, leftValue,
componentConfig.rightPanel?.dataFilter, // 🆕 데이터 필터 전달
); );
setRightData(joinedData || []); // 모든 관련 레코드 (배열) setRightData(joinedData || []); // 모든 관련 레코드 (배열)
} }

View File

@ -9,12 +9,13 @@ import { Slider } from "@/components/ui/slider";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; // Accordion 제거 - 단순 섹션으로 변경
import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react"; import { Check, ChevronsUpDown, ArrowRight, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { SplitPanelLayoutConfig } from "./types"; import { SplitPanelLayoutConfig } from "./types";
import { TableInfo, ColumnInfo } from "@/types/screen"; import { TableInfo, ColumnInfo } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
interface SplitPanelLayoutConfigPanelProps { interface SplitPanelLayoutConfigPanelProps {
config: SplitPanelLayoutConfig; config: SplitPanelLayoutConfig;
@ -325,14 +326,9 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</Select> </Select>
</div> </div>
{/* 좌측 패널 설정 (Accordion) */} {/* 좌측 패널 설정 */}
<Accordion type="single" collapsible defaultValue="left-panel" className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="left-panel" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> ()</h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline">
()
</AccordionTrigger>
<AccordionContent className="overflow-visible">
<div className="space-y-4 pt-2">
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -1018,19 +1014,30 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
</div> </div>
)} )}
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 우측 패널 설정 (Accordion) */} {/* 좌측 패널 데이터 필터링 */}
<Accordion type="single" collapsible defaultValue="right-panel" className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="right-panel" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> </h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline"> <p className="text-xs text-muted-foreground">
({relationshipType === "detail" ? "상세" : "조인"})
</AccordionTrigger> </p>
<AccordionContent className="overflow-visible"> <DataFilterConfigPanel
<div className="space-y-4 pt-2"> tableName={config.leftPanel?.tableName || screenTableName}
columns={leftTableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
} as any))}
config={config.leftPanel?.dataFilter}
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
/>
</div>
{/* 우측 패널 설정 */}
<div className="space-y-4 border-t pt-4 mt-4">
<h3 className="text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조인"})</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
@ -1672,19 +1679,29 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
</div> </div>
</div> </div>
)} )}
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* 레이아웃 설정 (Accordion) */} {/* 우측 패널 데이터 필터링 */}
<Accordion type="single" collapsible className="w-full"> <div className="space-y-4 border-t pt-4 mt-4">
<AccordionItem value="layout" className="border rounded-lg px-4"> <h3 className="text-sm font-semibold"> </h3>
<AccordionTrigger className="text-sm font-semibold hover:no-underline"> <p className="text-xs text-muted-foreground">
</AccordionTrigger> </p>
<AccordionContent className="overflow-visible"> <DataFilterConfigPanel
<div className="space-y-4 pt-2"> tableName={config.rightPanel?.tableName}
columns={rightTableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.columnLabel || col.columnName,
dataType: col.dataType || "text",
input_type: (col as any).input_type,
} as any))}
config={config.rightPanel?.dataFilter}
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
/>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-4 border-t pt-4 mt-4">
<div className="space-y-2"> <div className="space-y-2">
<Label> : {config.splitRatio || 30}%</Label> <Label> : {config.splitRatio || 30}%</Label>
@ -1712,10 +1729,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
onCheckedChange={(checked) => updateConfig({ autoLoad: checked })} onCheckedChange={(checked) => updateConfig({ autoLoad: checked })}
/> />
</div> </div>
</div> </div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div> </div>
); );
}; };

View File

@ -2,6 +2,8 @@
* SplitPanelLayout * SplitPanelLayout
*/ */
import { DataFilterConfig } from "@/types/screen-management";
export interface SplitPanelLayoutConfig { export interface SplitPanelLayoutConfig {
// 좌측 패널 설정 // 좌측 패널 설정
leftPanel: { leftPanel: {
@ -52,6 +54,9 @@ export interface SplitPanelLayoutConfig {
hoverable?: boolean; // 호버 효과 hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정 stickyHeader?: boolean; // 헤더 고정
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
}; };
// 우측 패널 설정 // 우측 패널 설정
@ -105,6 +110,9 @@ export interface SplitPanelLayoutConfig {
hoverable?: boolean; // 호버 효과 hoverable?: boolean; // 호버 효과
stickyHeader?: boolean; // 헤더 고정 stickyHeader?: boolean; // 헤더 고정
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
}; };
// 레이아웃 설정 // 레이아웃 설정

View File

@ -802,6 +802,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// ======================================== // ========================================
const fetchTableDataInternal = useCallback(async () => { const fetchTableDataInternal = useCallback(async () => {
console.log("📡 [TableList] fetchTableDataInternal 호출됨", {
tableName: tableConfig.selectedTable,
isDesignMode,
currentPage,
});
if (!tableConfig.selectedTable || isDesignMode) { if (!tableConfig.selectedTable || isDesignMode) {
setData([]); setData([]);
setTotalPages(0); setTotalPages(0);
@ -809,11 +815,6 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return; return;
} }
// 테이블명 확인 로그 (개발 시에만)
// console.log("🔍 fetchTableDataInternal - selectedTable:", tableConfig.selectedTable);
// console.log("🔍 selectedTable 타입:", typeof tableConfig.selectedTable);
// console.log("🔍 전체 tableConfig:", tableConfig);
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -834,6 +835,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable, referenceTable: col.additionalJoinInfo!.referenceTable,
})); }));
console.log("🔍 [TableList] API 호출 시작", {
tableName: tableConfig.selectedTable,
page,
pageSize,
sortBy,
sortOrder,
});
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원) // 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, { const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
page, page,
@ -843,8 +852,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
search: filters, search: filters,
enableEntityJoin: true, enableEntityJoin: true,
additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined, additionalJoinColumns: entityJoinColumns.length > 0 ? entityJoinColumns : undefined,
dataFilter: tableConfig.dataFilter, // 🆕 데이터 필터 전달
}); });
// 실제 데이터의 item_number만 추출하여 중복 확인
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
const uniqueItemNumbers = [...new Set(itemNumbers)];
console.log("✅ [TableList] API 응답 받음");
console.log(` - dataLength: ${response.data?.length || 0}`);
console.log(` - total: ${response.total}`);
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
setData(response.data || []); setData(response.data || []);
setTotalPages(response.totalPages || 0); setTotalPages(response.totalPages || 0);
setTotalItems(response.total || 0); setTotalItems(response.total || 0);
@ -1354,28 +1375,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
); );
} }
// 카테고리 타입: 배지로 표시 // 카테고리 타입: 배지로 표시 (배지 없음 옵션 지원)
if (inputType === "category") { if (inputType === "category") {
if (!value) return ""; if (!value) return "";
const mapping = categoryMappings[column.columnName]; const mapping = categoryMappings[column.columnName];
const categoryData = mapping?.[String(value)]; const categoryData = mapping?.[String(value)];
// console.log(`🎨 [카테고리 배지] ${column.columnName}:`, {
// value,
// stringValue: String(value),
// mapping,
// categoryData,
// hasMapping: !!mapping,
// hasCategoryData: !!categoryData,
// allCategoryMappings: categoryMappings, // 전체 매핑 확인
// categoryMappingsKeys: Object.keys(categoryMappings),
// });
// 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상 // 매핑 데이터가 있으면 라벨과 색상 사용, 없으면 코드값과 기본색상
const displayLabel = categoryData?.label || String(value); const displayLabel = categoryData?.label || String(value);
const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상 const displayColor = categoryData?.color || "#64748b"; // 기본 slate 색상
// 배지 없음 옵션: color가 "none"이면 텍스트만 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
const { Badge } = require("@/components/ui/badge"); const { Badge } = require("@/components/ui/badge");
return ( return (
<Badge <Badge
@ -1716,6 +1731,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]); }, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => { useEffect(() => {
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
isDesignMode,
tableName: tableConfig.selectedTable,
currentPage,
sortColumn,
sortDirection,
});
if (!isDesignMode && tableConfig.selectedTable) { if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced(); fetchTableDataDebounced();
} }
@ -1730,7 +1753,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
refreshKey, refreshKey,
refreshTrigger, // 강제 새로고침 트리거 refreshTrigger, // 강제 새로고침 트리거
isDesignMode, isDesignMode,
fetchTableDataDebounced, // fetchTableDataDebounced 제거: useCallback 재생성으로 인한 무한 루프 방지
]); ]);
useEffect(() => { useEffect(() => {
@ -2157,9 +2180,18 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div> </div>
</td> </td>
</tr> </tr>
) : groupByColumns.length > 0 && groupedData.length > 0 ? ( ) : (() => {
console.log("🔍 [TableList] 렌더링 조건 체크", {
groupByColumns: groupByColumns.length,
groupedDataLength: groupedData.length,
willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
dataLength: data.length,
});
return groupByColumns.length > 0 && groupedData.length > 0;
})() ? (
// 그룹화된 렌더링 // 그룹화된 렌더링
groupedData.map((group) => { groupedData.map((group) => {
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
const isCollapsed = collapsedGroups.has(group.groupKey); const isCollapsed = collapsedGroups.has(group.groupKey);
return ( return (
<React.Fragment key={group.groupKey}> <React.Fragment key={group.groupKey}>
@ -2252,7 +2284,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}) })
) : ( ) : (
// 일반 렌더링 (그룹 없음) // 일반 렌더링 (그룹 없음)
data.map((row, index) => ( (() => {
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
return data;
})().map((row, index) => (
<tr <tr
key={index} key={index}
className={cn( className={cn(

View File

@ -13,6 +13,7 @@ import { Plus, Trash2, ArrowUp, ArrowDown, ChevronsUpDown, Check } from "lucide-
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFilterConfigPanel";
export interface TableListConfigPanelProps { export interface TableListConfigPanelProps {
config: TableListConfig; config: TableListConfig;
@ -47,7 +48,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]); const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false); const [loadingTables, setLoadingTables] = useState(false);
const [availableColumns, setAvailableColumns] = useState< const [availableColumns, setAvailableColumns] = useState<
Array<{ columnName: string; dataType: string; label?: string }> Array<{ columnName: string; dataType: string; label?: string; input_type?: string }>
>([]); >([]);
const [entityJoinColumns, setEntityJoinColumns] = useState<{ const [entityJoinColumns, setEntityJoinColumns] = useState<{
availableColumns: Array<{ availableColumns: Array<{
@ -157,6 +158,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
columnName: column.columnName || column.name, columnName: column.columnName || column.name,
dataType: column.dataType || column.type || "text", dataType: column.dataType || column.type || "text",
label: column.label || column.displayName || column.columnLabel || column.columnName || column.name, label: column.label || column.displayName || column.columnLabel || column.columnName || column.name,
input_type: column.input_type || column.inputType, // 🆕 input_type 추가
})); }));
setAvailableColumns(mappedColumns); setAvailableColumns(mappedColumns);
@ -189,6 +191,7 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
columnName: col.columnName, columnName: col.columnName,
dataType: col.dataType, dataType: col.dataType,
label: col.displayName || col.columnName, label: col.displayName || col.columnName,
input_type: col.input_type || col.inputType, // 🆕 input_type 추가
})), })),
); );
} }
@ -1140,6 +1143,28 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
</div> </div>
)} )}
{/* 🆕 데이터 필터링 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<hr className="border-border" />
<DataFilterConfigPanel
tableName={config.selectedTable || screenTableName}
columns={availableColumns.map((col) => ({
columnName: col.columnName,
columnLabel: col.label || col.columnName,
dataType: col.dataType,
input_type: col.input_type, // 🆕 실제 input_type 전달
} as any))}
config={config.dataFilter}
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
/>
</div>
</div> </div>
</div> </div>
); );

View File

@ -173,6 +173,9 @@ export interface CheckboxConfig {
/** /**
* TableList * TableList
*/ */
import { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig { export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정 // 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table" displayMode?: "table" | "card"; // 기본: "table"
@ -225,6 +228,9 @@ export interface TableListConfig extends ComponentConfig {
autoLoad: boolean; autoLoad: boolean;
refreshInterval?: number; // 초 단위 refreshInterval?: number; // 초 단위
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 이벤트 핸들러 // 이벤트 핸들러
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void;

View File

@ -137,6 +137,9 @@ export interface DataTableComponent extends BaseComponent {
filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code) filterColumn: string; // 필터링할 테이블 컬럼 (예: company_code, dept_code)
userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드 userField: 'companyCode' | 'userId' | 'deptCode'; // 사용자 정보에서 가져올 필드
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
} }
/** /**
@ -173,6 +176,8 @@ export interface FlowComponent extends BaseComponent {
stepColumnConfig?: { stepColumnConfig?: {
[stepId: number]: FlowStepColumnConfig; [stepId: number]: FlowStepColumnConfig;
}; };
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
} }
/** /**
@ -435,6 +440,26 @@ export interface DataTableFilter {
logicalOperator?: "AND" | "OR"; logicalOperator?: "AND" | "OR";
} }
/**
* ( )
*/
export interface ColumnFilter {
id: string;
columnName: string; // 필터링할 컬럼명
operator: "equals" | "not_equals" | "in" | "not_in" | "contains" | "starts_with" | "ends_with" | "is_null" | "is_not_null";
value: string | string[]; // 필터 값 (in/not_in은 배열)
valueType: "static" | "category" | "code"; // 값 타입
}
/**
* ( )
*/
export interface DataFilterConfig {
enabled: boolean; // 필터 활성화 여부
filters: ColumnFilter[]; // 필터 조건 목록
matchType: "all" | "any"; // AND(모두 만족) / OR(하나 이상 만족)
}
// ===== 파일 업로드 관련 ===== // ===== 파일 업로드 관련 =====
/** /**