diff --git a/.cursor/rules/multilang-component-guide.mdc b/.cursor/rules/multilang-component-guide.mdc index 60bdc0ec..97140312 100644 --- a/.cursor/rules/multilang-component-guide.mdc +++ b/.cursor/rules/multilang-component-guide.mdc @@ -140,7 +140,7 @@ if (comp.componentType === "my-new-component") { if (config?.title) { addLabel({ id: `${comp.id}_title`, - componentId: `${comp.id}_title`, + componentId: `${comp.id}_title`,- label: config.title, type: "title", parentType: "my-new-component", diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index ce7b9c7f..c86b0064 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1417,6 +1417,75 @@ export async function updateMenu( } } +/** + * 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수 + */ +async function collectAllChildMenuIds(parentObjid: number): Promise { + const allIds: number[] = []; + + // 직접 자식 메뉴들 조회 + const children = await query( + `SELECT objid FROM menu_info WHERE parent_obj_id = $1`, + [parentObjid] + ); + + for (const child of children) { + allIds.push(child.objid); + // 자식의 자식들도 재귀적으로 수집 + const grandChildren = await collectAllChildMenuIds(child.objid); + allIds.push(...grandChildren); + } + + return allIds; +} + +/** + * 메뉴 및 관련 데이터 정리 헬퍼 함수 + */ +async function cleanupMenuRelatedData(menuObjid: number): Promise { + // 1. category_column_mapping에서 menu_objid를 NULL로 설정 + await query( + `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 2. code_category에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 3. code_info에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 4. numbering_rules에서 menu_objid를 NULL로 설정 + await query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 5. rel_menu_auth에서 관련 권한 삭제 + await query( + `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, + [menuObjid] + ); + + // 6. screen_menu_assignments에서 관련 할당 삭제 + await query( + `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, + [menuObjid] + ); + + // 7. screen_groups에서 menu_objid를 NULL로 설정 + await query( + `UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); +} + /** * 메뉴 삭제 */ @@ -1443,7 +1512,7 @@ export async function deleteMenu( // 삭제하려는 메뉴 조회 const currentMenu = await queryOne( - `SELECT objid, company_code FROM menu_info WHERE objid = $1`, + `SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`, [Number(menuId)] ); @@ -1478,67 +1547,50 @@ export async function deleteMenu( } } - // 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리 const menuObjid = Number(menuId); - // 1. category_column_mapping에서 menu_objid를 NULL로 설정 - await query( - `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); + // 하위 메뉴들 재귀적으로 수집 + const childMenuIds = await collectAllChildMenuIds(menuObjid); + const allMenuIdsToDelete = [menuObjid, ...childMenuIds]; - // 2. code_category에서 menu_objid를 NULL로 설정 - await query( - `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 3. code_info에서 menu_objid를 NULL로 설정 - await query( - `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 4. numbering_rules에서 menu_objid를 NULL로 설정 - await query( - `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, - [menuObjid] - ); - - // 5. rel_menu_auth에서 관련 권한 삭제 - await query( - `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, - [menuObjid] - ); - - // 6. screen_menu_assignments에서 관련 할당 삭제 - await query( - `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, - [menuObjid] - ); + logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}개`, { + menuName: currentMenu.menu_name_kor, + totalCount: allMenuIdsToDelete.length, + childMenuIds, + }); - logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리 + for (const objid of allMenuIdsToDelete) { + await cleanupMenuRelatedData(objid); + } - // Raw Query를 사용한 메뉴 삭제 - const [deletedMenu] = await query( - `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [menuObjid] - ); + logger.info("메뉴 관련 데이터 정리 완료", { + menuObjid, + totalCleaned: allMenuIdsToDelete.length + }); - logger.info("메뉴 삭제 성공", { deletedMenu }); + // 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피) + // 가장 깊은 하위부터 삭제해야 하므로 역순으로 + const reversedIds = [...allMenuIdsToDelete].reverse(); + + for (const objid of reversedIds) { + await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]); + } + + logger.info("메뉴 삭제 성공", { + deletedMenuObjid: menuObjid, + deletedMenuName: currentMenu.menu_name_kor, + totalDeleted: allMenuIdsToDelete.length, + }); const response: ApiResponse = { success: true, - message: "메뉴가 성공적으로 삭제되었습니다.", + message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`, data: { - objid: deletedMenu.objid.toString(), - menuNameKor: deletedMenu.menu_name_kor, - menuNameEng: deletedMenu.menu_name_eng, - menuUrl: deletedMenu.menu_url, - menuDesc: deletedMenu.menu_desc, - status: deletedMenu.status, - writer: deletedMenu.writer, - regdate: new Date(deletedMenu.regdate).toISOString(), + objid: menuObjid.toString(), + menuNameKor: currentMenu.menu_name_kor, + deletedCount: allMenuIdsToDelete.length, + deletedChildCount: childMenuIds.length, }, }; @@ -1623,18 +1675,49 @@ export async function deleteMenusBatch( } } + // 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함) + const allMenuIdsToDelete = new Set(); + + for (const menuId of menuIds) { + const objid = Number(menuId); + allMenuIdsToDelete.add(objid); + + // 하위 메뉴들 재귀적으로 수집 + const childMenuIds = await collectAllChildMenuIds(objid); + childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id))); + } + + const allIdsArray = Array.from(allMenuIdsToDelete); + + logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}개`, { + selectedMenuIds: menuIds, + totalWithChildren: allIdsArray.length, + }); + + // 모든 삭제 대상 메뉴에 대해 관련 데이터 정리 + for (const objid of allIdsArray) { + await cleanupMenuRelatedData(objid); + } + + logger.info("메뉴 관련 데이터 정리 완료", { + totalCleaned: allIdsArray.length + }); + // Raw Query를 사용한 메뉴 일괄 삭제 let deletedCount = 0; let failedCount = 0; const deletedMenus: any[] = []; const failedMenuIds: string[] = []; + // 하위 메뉴부터 삭제하기 위해 역순으로 정렬 + const reversedIds = [...allIdsArray].reverse(); + // 각 메뉴 ID에 대해 삭제 시도 - for (const menuId of menuIds) { + for (const menuObjid of reversedIds) { try { const result = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); if (result.length > 0) { @@ -1645,20 +1728,20 @@ export async function deleteMenusBatch( }); } else { failedCount++; - failedMenuIds.push(menuId); + failedMenuIds.push(String(menuObjid)); } } catch (error) { - logger.error(`메뉴 삭제 실패 (ID: ${menuId}):`, error); + logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error); failedCount++; - failedMenuIds.push(menuId); + failedMenuIds.push(String(menuObjid)); } } logger.info("메뉴 일괄 삭제 완료", { - total: menuIds.length, + requested: menuIds.length, + totalWithChildren: allIdsArray.length, deletedCount, failedCount, - deletedMenus, failedMenuIds, }); diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b89ef902..43ccce32 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1,6 +1,7 @@ import { Request, Response } from "express"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +import { AuthenticatedRequest } from "../types/auth"; import { syncScreenGroupsToMenu, syncMenuToScreenGroups, @@ -16,9 +17,9 @@ const pool = getPool(); // ============================================================ // 화면 그룹 목록 조회 -export const getScreenGroups = async (req: Request, res: Response) => { +export const getScreenGroups = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { page = 1, size = 20, searchTerm } = req.query; const offset = (parseInt(page as string) - 1) * parseInt(size as string); @@ -90,10 +91,10 @@ export const getScreenGroups = async (req: Request, res: Response) => { }; // 화면 그룹 상세 조회 -export const getScreenGroup = async (req: Request, res: Response) => { +export const getScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; let query = ` SELECT sg.*, @@ -136,10 +137,10 @@ export const getScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 생성 -export const createScreenGroup = async (req: Request, res: Response) => { +export const createScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; if (!group_name || !group_code) { @@ -210,10 +211,10 @@ export const createScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 수정 -export const updateScreenGroup = async (req: Request, res: Response) => { +export const updateScreenGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const userCompanyCode = (req.user as any).companyCode; + const userCompanyCode = req.user?.companyCode || "*"; const { group_name, group_code, main_table_name, description, icon, display_order, is_active, parent_group_id, target_company_code } = req.body; // 회사 코드 결정: 최고 관리자가 특정 회사를 선택한 경우 해당 회사로, 아니면 현재 그룹의 회사 유지 @@ -299,11 +300,11 @@ export const updateScreenGroup = async (req: Request, res: Response) => { }; // 화면 그룹 삭제 -export const deleteScreenGroup = async (req: Request, res: Response) => { +export const deleteScreenGroup = async (req: AuthenticatedRequest, res: Response) => { const client = await pool.connect(); try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; await client.query('BEGIN'); @@ -366,10 +367,10 @@ export const deleteScreenGroup = async (req: Request, res: Response) => { // ============================================================ // 그룹에 화면 추가 -export const addScreenToGroup = async (req: Request, res: Response) => { +export const addScreenToGroup = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { group_id, screen_id, screen_role, display_order, is_default } = req.body; if (!group_id || !screen_id) { @@ -406,10 +407,10 @@ export const addScreenToGroup = async (req: Request, res: Response) => { }; // 그룹에서 화면 제거 -export const removeScreenFromGroup = async (req: Request, res: Response) => { +export const removeScreenFromGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_group_screens WHERE id = $1`; const params: any[] = [id]; @@ -437,10 +438,10 @@ export const removeScreenFromGroup = async (req: Request, res: Response) => { }; // 그룹 내 화면 순서/역할 수정 -export const updateScreenInGroup = async (req: Request, res: Response) => { +export const updateScreenInGroup = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_role, display_order, is_default } = req.body; let query = ` @@ -476,9 +477,9 @@ export const updateScreenInGroup = async (req: Request, res: Response) => { // ============================================================ // 화면 필드 조인 목록 조회 -export const getFieldJoins = async (req: Request, res: Response) => { +export const getFieldJoins = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_id } = req.query; let query = ` @@ -517,10 +518,10 @@ export const getFieldJoins = async (req: Request, res: Response) => { }; // 화면 필드 조인 생성 -export const createFieldJoin = async (req: Request, res: Response) => { +export const createFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { screen_id, layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -558,10 +559,10 @@ export const createFieldJoin = async (req: Request, res: Response) => { }; // 화면 필드 조인 수정 -export const updateFieldJoin = async (req: Request, res: Response) => { +export const updateFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { layout_id, component_id, field_name, save_table, save_column, join_table, join_column, display_column, @@ -603,10 +604,10 @@ export const updateFieldJoin = async (req: Request, res: Response) => { }; // 화면 필드 조인 삭제 -export const deleteFieldJoin = async (req: Request, res: Response) => { +export const deleteFieldJoin = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_field_joins WHERE id = $1`; const params: any[] = [id]; @@ -637,9 +638,9 @@ export const deleteFieldJoin = async (req: Request, res: Response) => { // ============================================================ // 데이터 흐름 목록 조회 -export const getDataFlows = async (req: Request, res: Response) => { +export const getDataFlows = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { group_id, source_screen_id } = req.query; let query = ` @@ -687,10 +688,10 @@ export const getDataFlows = async (req: Request, res: Response) => { }; // 데이터 흐름 생성 -export const createDataFlow = async (req: Request, res: Response) => { +export const createDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -726,10 +727,10 @@ export const createDataFlow = async (req: Request, res: Response) => { }; // 데이터 흐름 수정 -export const updateDataFlow = async (req: Request, res: Response) => { +export const updateDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { group_id, source_screen_id, source_action, target_screen_id, target_action, data_mapping, flow_type, flow_label, condition_expression, is_active @@ -769,10 +770,10 @@ export const updateDataFlow = async (req: Request, res: Response) => { }; // 데이터 흐름 삭제 -export const deleteDataFlow = async (req: Request, res: Response) => { +export const deleteDataFlow = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_data_flows WHERE id = $1`; const params: any[] = [id]; @@ -803,9 +804,9 @@ export const deleteDataFlow = async (req: Request, res: Response) => { // ============================================================ // 화면-테이블 관계 목록 조회 -export const getTableRelations = async (req: Request, res: Response) => { +export const getTableRelations = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { screen_id, group_id } = req.query; let query = ` @@ -852,10 +853,10 @@ export const getTableRelations = async (req: Request, res: Response) => { }; // 화면-테이블 관계 생성 -export const createTableRelation = async (req: Request, res: Response) => { +export const createTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { - const companyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { group_id, screen_id, table_name, relation_type, crud_operations, description, is_active } = req.body; if (!screen_id || !table_name) { @@ -885,10 +886,10 @@ export const createTableRelation = async (req: Request, res: Response) => { }; // 화면-테이블 관계 수정 -export const updateTableRelation = async (req: Request, res: Response) => { +export const updateTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; const { group_id, table_name, relation_type, crud_operations, description, is_active } = req.body; let query = ` @@ -920,10 +921,10 @@ export const updateTableRelation = async (req: Request, res: Response) => { }; // 화면-테이블 관계 삭제 -export const deleteTableRelation = async (req: Request, res: Response) => { +export const deleteTableRelation = async (req: AuthenticatedRequest, res: Response) => { try { const { id } = req.params; - const companyCode = (req.user as any).companyCode; + const companyCode = req.user?.companyCode || "*"; let query = `DELETE FROM screen_table_relations WHERE id = $1`; const params: any[] = [id]; @@ -953,7 +954,7 @@ export const deleteTableRelation = async (req: Request, res: Response) => { // ============================================================ // 화면 레이아웃 요약 조회 (위젯 타입별 개수, 라벨 목록) -export const getScreenLayoutSummary = async (req: Request, res: Response) => { +export const getScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => { try { const { screenId } = req.params; @@ -1021,7 +1022,7 @@ export const getScreenLayoutSummary = async (req: Request, res: Response) => { }; // 여러 화면의 레이아웃 요약 일괄 조회 (미니어처 렌더링용 좌표 포함) -export const getMultipleScreenLayoutSummary = async (req: Request, res: Response) => { +export const getMultipleScreenLayoutSummary = async (req: AuthenticatedRequest, res: Response) => { try { const { screenIds } = req.body; @@ -1221,7 +1222,7 @@ export const getMultipleScreenLayoutSummary = async (req: Request, res: Response // ============================================================ // 여러 화면의 서브 테이블 정보 조회 (메인 테이블 → 서브 테이블 관계) -export const getScreenSubTables = async (req: Request, res: Response) => { +export const getScreenSubTables = async (req: AuthenticatedRequest, res: Response) => { try { const { screenIds } = req.body; @@ -2060,10 +2061,10 @@ export const getScreenSubTables = async (req: Request, res: Response) => { * 화면관리 → 메뉴 동기화 * screen_groups를 menu_info로 동기화 */ -export const syncScreenGroupsToMenuController = async (req: Request, res: Response) => { +export const syncScreenGroupsToMenuController = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { targetCompanyCode } = req.body; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 @@ -2111,10 +2112,10 @@ export const syncScreenGroupsToMenuController = async (req: Request, res: Respon * 메뉴 → 화면관리 동기화 * menu_info를 screen_groups로 동기화 */ -export const syncMenuToScreenGroupsController = async (req: Request, res: Response) => { +export const syncMenuToScreenGroupsController = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; const { targetCompanyCode } = req.body; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 @@ -2161,9 +2162,9 @@ export const syncMenuToScreenGroupsController = async (req: Request, res: Respon /** * 동기화 상태 조회 */ -export const getSyncStatusController = async (req: Request, res: Response) => { +export const getSyncStatusController = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; + const userCompanyCode = req.user?.companyCode || "*"; const { targetCompanyCode } = req.query; // 최고 관리자가 특정 회사를 지정한 경우 해당 회사로 @@ -2200,10 +2201,10 @@ export const getSyncStatusController = async (req: Request, res: Response) => { * 전체 회사 동기화 * 모든 회사에 대해 양방향 동기화 수행 (최고 관리자만) */ -export const syncAllCompaniesController = async (req: Request, res: Response) => { +export const syncAllCompaniesController = async (req: AuthenticatedRequest, res: Response) => { try { - const userCompanyCode = (req.user as any).companyCode; - const userId = (req.user as any).userId; + const userCompanyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || ""; // 최고 관리자만 전체 동기화 가능 if (userCompanyCode !== "*") { diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index a163f30c..f8b808d3 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -2090,7 +2090,7 @@ export class MenuCopyService { menu.menu_url, menu.menu_desc, userId, - menu.status, + 'active', // 복제된 메뉴는 항상 활성화 상태 menu.system_name, targetCompanyCode, // 새 회사 코드 menu.lang_key, diff --git a/backend-node/src/services/menuScreenSyncService.ts b/backend-node/src/services/menuScreenSyncService.ts index 13c77ed6..d6f27e07 100644 --- a/backend-node/src/services/menuScreenSyncService.ts +++ b/backend-node/src/services/menuScreenSyncService.ts @@ -142,7 +142,7 @@ export async function syncScreenGroupsToMenu( const newObjid = Date.now(); const createRootQuery = ` INSERT INTO menu_info (objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status) - VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'Y') + VALUES ($1, 0, '사용자', 'User', 1, 1, $2, $3, NOW(), 'active') RETURNING objid `; const createRootResult = await client.query(createRootQuery, [newObjid, companyCode, userId]); @@ -159,12 +159,36 @@ export async function syncScreenGroupsToMenu( groupIdToName.set(g.id, g.group_name?.trim().toLowerCase() || ''); }); - // 5. 각 screen_group 처리 + // 5. 최상위 회사 폴더 ID 찾기 (level 0, parent_group_id IS NULL) + // 이 폴더는 메뉴로 생성하지 않고, 하위 폴더들을 사용자 루트 바로 아래에 배치 + const topLevelCompanyFolderIds = new Set(); + for (const group of screenGroupsResult.rows) { + if (group.group_level === 0 && group.parent_group_id === null) { + topLevelCompanyFolderIds.add(group.id); + // 최상위 폴더 → 사용자 루트에 매핑 (하위 폴더의 부모로 사용) + groupToMenuMap.set(group.id, userMenuRootObjid!); + logger.info("최상위 회사 폴더 스킵", { groupId: group.id, groupName: group.group_name }); + } + } + + // 6. 각 screen_group 처리 for (const group of screenGroupsResult.rows) { const groupId = group.id; const groupName = group.group_name?.trim(); const groupNameLower = groupName?.toLowerCase() || ''; + // 최상위 회사 폴더는 메뉴로 생성하지 않고 스킵 + if (topLevelCompanyFolderIds.has(groupId)) { + result.skipped++; + result.details.push({ + action: 'skipped', + sourceName: groupName, + sourceId: groupId, + reason: '최상위 회사 폴더 (메뉴 생성 스킵)', + }); + continue; + } + // 이미 연결된 경우 - 실제로 메뉴가 존재하는지 확인 if (group.menu_objid) { const menuExists = existingMenuObjids.has(Number(group.menu_objid)); @@ -237,11 +261,17 @@ export async function syncScreenGroupsToMenu( const newObjid = Date.now() + groupId; // 고유 ID 보장 // 부모 메뉴 objid 결정 + // 우선순위: groupToMenuMap > parent_menu_objid (존재 확인 필수) let parentMenuObjid = userMenuRootObjid; - if (group.parent_group_id && group.parent_menu_objid) { - parentMenuObjid = Number(group.parent_menu_objid); - } else if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + if (group.parent_group_id && groupToMenuMap.has(group.parent_group_id)) { + // 현재 트랜잭션에서 생성된 부모 메뉴 사용 parentMenuObjid = groupToMenuMap.get(group.parent_group_id)!; + } else if (group.parent_group_id && group.parent_menu_objid) { + // 기존 parent_menu_objid가 실제로 존재하는지 확인 + const parentMenuExists = existingMenuObjids.has(Number(group.parent_menu_objid)); + if (parentMenuExists) { + parentMenuObjid = Number(group.parent_menu_objid); + } } // 같은 부모 아래에서 가장 높은 seq 조회 후 +1 @@ -261,7 +291,7 @@ export async function syncScreenGroupsToMenu( INSERT INTO menu_info ( objid, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_type, company_code, writer, regdate, status, screen_group_id, menu_desc - ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'Y', $8, $9) + ) VALUES ($1, $2, $3, $4, $5, 1, $6, $7, NOW(), 'active', $8, $9) RETURNING objid `; await client.query(insertMenuQuery, [ diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 2e67040a..9dad459c 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -1314,7 +1314,7 @@ export class TableManagementService { // 각 값을 LIKE 또는 = 조건으로 처리 const conditions: string[] = []; const values: any[] = []; - + value.forEach((v: any, idx: number) => { const safeValue = String(v).trim(); // 정확히 일치하거나, 콤마로 구분된 값 중 하나로 포함 @@ -1323,17 +1323,24 @@ export class TableManagementService { // - "2," 로 시작 // - ",2" 로 끝남 // - ",2," 중간에 포함 - const paramBase = paramIndex + (idx * 4); + const paramBase = paramIndex + idx * 4; conditions.push(`( ${columnName}::text = $${paramBase} OR ${columnName}::text LIKE $${paramBase + 1} OR ${columnName}::text LIKE $${paramBase + 2} OR ${columnName}::text LIKE $${paramBase + 3} )`); - values.push(safeValue, `${safeValue},%`, `%,${safeValue}`, `%,${safeValue},%`); + values.push( + safeValue, + `${safeValue},%`, + `%,${safeValue}`, + `%,${safeValue},%` + ); }); - logger.info(`🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]`); + logger.info( + `🔍 다중 값 배열 검색: ${columnName} IN [${value.join(", ")}]` + ); return { whereClause: `(${conditions.join(" OR ")})`, values, @@ -1772,21 +1779,29 @@ export class TableManagementService { // contains 연산자 (기본): 참조 테이블의 표시 컬럼으로 검색 const referenceColumn = entityTypeInfo.referenceColumn || "id"; const referenceTable = entityTypeInfo.referenceTable; - + // displayColumn이 비어있거나 "none"이면 참조 테이블에서 자동 감지 (entityJoinService와 동일한 로직) let displayColumn = entityTypeInfo.displayColumn; - if (!displayColumn || displayColumn === "none" || displayColumn === "") { - displayColumn = await this.findDisplayColumnForTable(referenceTable, referenceColumn); + if ( + !displayColumn || + displayColumn === "none" || + displayColumn === "" + ) { + displayColumn = await this.findDisplayColumnForTable( + referenceTable, + referenceColumn + ); logger.info( `🔍 [buildEntitySearchCondition] displayColumn 자동 감지: ${referenceTable} -> ${displayColumn}` ); } // 참조 테이블의 표시 컬럼으로 검색 + // 🔧 main. 접두사 추가: EXISTS 서브쿼리에서 외부 테이블 참조 시 명시적으로 지정 return { whereClause: `EXISTS ( SELECT 1 FROM ${referenceTable} ref - WHERE ref.${referenceColumn} = ${columnName} + WHERE ref.${referenceColumn} = main.${columnName} AND ref.${displayColumn} ILIKE $${paramIndex} )`, values: [`%${value}%`], @@ -2150,14 +2165,14 @@ export class TableManagementService { // 안전한 테이블명 검증 const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, ""); - // 전체 개수 조회 - const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} ${whereClause}`; + // 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요) + const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`; const countResult = await query(countQuery, searchValues); const total = parseInt(countResult[0].count); - // 데이터 조회 + // 데이터 조회 (main 별칭 추가) const dataQuery = ` - SELECT * FROM ${safeTableName} + SELECT main.* FROM ${safeTableName} main ${whereClause} ${orderClause} LIMIT $${paramIndex} OFFSET $${paramIndex + 1} @@ -2494,7 +2509,7 @@ export class TableManagementService { skippedColumns.push(column); return; } - + const dataType = columnTypeMap.get(column) || "text"; setConditions.push( `"${column}" = $${paramIndex}::${this.getPostgreSQLType(dataType)}` @@ -2506,7 +2521,9 @@ export class TableManagementService { }); if (skippedColumns.length > 0) { - logger.info(`⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}`); + logger.info( + `⚠️ 테이블에 존재하지 않는 컬럼 스킵: ${skippedColumns.join(", ")}` + ); } // WHERE 조건 생성 (PRIMARY KEY 우선, 없으면 모든 원본 데이터 사용) @@ -2776,10 +2793,14 @@ export class TableManagementService { // 실제 소스 컬럼이 partner_id인데 프론트엔드가 customer_id로 추론하는 경우 대응 if (!baseJoinConfig && (additionalColumn as any).referenceTable) { baseJoinConfig = joinConfigs.find( - (config) => config.referenceTable === (additionalColumn as any).referenceTable + (config) => + config.referenceTable === + (additionalColumn as any).referenceTable ); if (baseJoinConfig) { - logger.info(`🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}`); + logger.info( + `🔄 referenceTable로 조인 설정 찾음: ${(additionalColumn as any).referenceTable} → ${baseJoinConfig.sourceColumn}` + ); } } @@ -2787,25 +2808,31 @@ export class TableManagementService { // joinAlias에서 실제 컬럼명 추출 const sourceColumn = baseJoinConfig.sourceColumn; // 실제 소스 컬럼 (예: partner_id) const originalJoinAlias = additionalColumn.joinAlias; // 프론트엔드가 보낸 별칭 (예: customer_id_customer_name) - + // 🔄 프론트엔드가 잘못된 소스 컬럼으로 추론한 경우 처리 // customer_id_customer_name → customer_name 추출 (customer_id_ 부분 제거) // 또는 partner_id_customer_name → customer_name 추출 (partner_id_ 부분 제거) let actualColumnName: string; - + // 프론트엔드가 보낸 joinAlias에서 실제 컬럼명 추출 const frontendSourceColumn = additionalColumn.sourceColumn; // 프론트엔드가 추론한 소스 컬럼 (customer_id) if (originalJoinAlias.startsWith(`${frontendSourceColumn}_`)) { // 프론트엔드가 추론한 소스 컬럼으로 시작하면 그 부분 제거 - actualColumnName = originalJoinAlias.replace(`${frontendSourceColumn}_`, ""); + actualColumnName = originalJoinAlias.replace( + `${frontendSourceColumn}_`, + "" + ); } else if (originalJoinAlias.startsWith(`${sourceColumn}_`)) { // 실제 소스 컬럼으로 시작하면 그 부분 제거 - actualColumnName = originalJoinAlias.replace(`${sourceColumn}_`, ""); + actualColumnName = originalJoinAlias.replace( + `${sourceColumn}_`, + "" + ); } else { // 어느 것도 아니면 원본 사용 actualColumnName = originalJoinAlias; } - + // 🆕 올바른 joinAlias 재생성 (실제 소스 컬럼 기반) const correctedJoinAlias = `${sourceColumn}_${actualColumnName}`; @@ -3199,8 +3226,10 @@ export class TableManagementService { } // Entity 조인 컬럼 검색이 있는지 확인 (기본 조인 + 추가 조인 컬럼 모두 포함) + // 🔧 sourceColumn도 포함: search={"order_no":"..."} 형태도 Entity 검색으로 인식 const allEntityColumns = [ ...joinConfigs.map((config) => config.aliasColumn), + ...joinConfigs.map((config) => config.sourceColumn), // 🔧 소스 컬럼도 포함 // 추가 조인 컬럼들도 포함 (writer_dept_code, company_code_status 등) ...joinConfigs.flatMap((config) => { const additionalColumns = []; @@ -3606,8 +3635,10 @@ export class TableManagementService { }); // main. 접두사 추가 (조인 쿼리용) + // 🔧 이미 접두사(. 앞)가 있는 경우는 교체하지 않음 (ref.column, main.column 등) + // Negative lookbehind (?> { + ): Promise< + Array<{ + leftColumn: string; + rightColumn: string; + direction: "left_to_right" | "right_to_left"; + inputType: string; + displayColumn?: string; + }> + > { try { - logger.info(`두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}`); - + logger.info( + `두 테이블 간 엔티티 관계 감지 시작: ${leftTable} <-> ${rightTable}` + ); + const relations: Array<{ leftColumn: string; rightColumn: string; @@ -4806,12 +4844,17 @@ export class TableManagementService { logger.info(`엔티티 관계 감지 완료: ${relations.length}개 발견`); relations.forEach((rel, idx) => { - logger.info(` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})`); + logger.info( + ` ${idx + 1}. ${leftTable}.${rel.leftColumn} <-> ${rightTable}.${rel.rightColumn} (${rel.direction})` + ); }); return relations; } catch (error) { - logger.error(`엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, error); + logger.error( + `엔티티 관계 감지 실패: ${leftTable} <-> ${rightTable}`, + error + ); return []; } } diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index edd36816..7cd1310f 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -172,6 +172,7 @@ export function ScreenGroupTreeView({ const [syncStatus, setSyncStatus] = useState(null); const [isSyncing, setIsSyncing] = useState(false); const [syncDirection, setSyncDirection] = useState<"screen-to-menu" | "menu-to-screen" | "all" | null>(null); + const [syncProgress, setSyncProgress] = useState<{ message: string; detail?: string } | null>(null); // 회사 선택 (최고 관리자용) const { user } = useAuth(); @@ -328,14 +329,31 @@ export function ScreenGroupTreeView({ setIsSyncing(true); setSyncDirection(direction); + setSyncProgress({ + message: direction === "screen-to-menu" + ? "화면관리 → 메뉴 동기화 중..." + : "메뉴 → 화면관리 동기화 중...", + detail: "데이터를 분석하고 있습니다..." + }); try { + setSyncProgress({ + message: direction === "screen-to-menu" + ? "화면관리 → 메뉴 동기화 중..." + : "메뉴 → 화면관리 동기화 중...", + detail: "동기화 작업을 수행하고 있습니다..." + }); + const response = direction === "screen-to-menu" ? await syncScreenGroupsToMenu(targetCompanyCode) : await syncMenuToScreenGroups(targetCompanyCode); if (response.success) { const data = response.data; + setSyncProgress({ + message: "동기화 완료!", + detail: `생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` + }); toast.success( `동기화 완료: 생성 ${data?.created || 0}개, 연결 ${data?.linked || 0}개, 스킵 ${data?.skipped || 0}개` ); @@ -347,13 +365,17 @@ export function ScreenGroupTreeView({ setSyncStatus(statusResponse.data); } } else { + setSyncProgress(null); toast.error(`동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { + setSyncProgress(null); toast.error(`동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); + // 3초 후 진행 메시지 초기화 + setTimeout(() => setSyncProgress(null), 3000); } }; @@ -366,27 +388,42 @@ export function ScreenGroupTreeView({ setIsSyncing(true); setSyncDirection("all"); + setSyncProgress({ + message: "전체 회사 동기화 중...", + detail: "모든 회사의 데이터를 분석하고 있습니다..." + }); try { + setSyncProgress({ + message: "전체 회사 동기화 중...", + detail: "양방향 동기화 작업을 수행하고 있습니다..." + }); + const response = await syncAllCompanies(); if (response.success && response.data) { const data = response.data; + setSyncProgress({ + message: "전체 동기화 완료!", + detail: `${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` + }); toast.success( `전체 동기화 완료: ${data.totalCompanies}개 회사, 생성 ${data.totalCreated}개, 연결 ${data.totalLinked}개` ); // 그룹 데이터 새로고침 await loadGroupsData(); - // 동기화 다이얼로그 닫기 - setIsSyncDialogOpen(false); } else { + setSyncProgress(null); toast.error(`전체 동기화 실패: ${response.error || "알 수 없는 오류"}`); } } catch (error: any) { + setSyncProgress(null); toast.error(`전체 동기화 실패: ${error.message}`); } finally { setIsSyncing(false); setSyncDirection(null); + // 3초 후 진행 메시지 초기화 + setTimeout(() => setSyncProgress(null), 3000); } }; @@ -979,15 +1016,17 @@ export function ScreenGroupTreeView({ 그룹 추가 - +{isSuperAdmin && ( + + )} {/* 트리 목록 */} @@ -1816,7 +1855,23 @@ export function ScreenGroupTreeView({ {/* 메뉴-화면그룹 동기화 다이얼로그 */} - + + {/* 동기화 진행 중 오버레이 (삭제와 동일한 스타일) */} + {isSyncing && ( +
+ +

{syncProgress?.message || "동기화 중..."}

+ {syncProgress?.detail && ( +

{syncProgress.detail}

+ )} +
+
+
+
+ )} 메뉴-화면 동기화 diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx index bdc00019..53ad204d 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridComponent.tsx @@ -296,24 +296,6 @@ export const PivotGridComponent: React.FC = ({ onFieldDrop, onExpandChange, }) => { - // 디버깅 로그 - console.log("🔶 PivotGridComponent props:", { - title, - hasExternalData: !!externalData, - externalDataLength: externalData?.length, - initialFieldsLength: initialFields?.length, - }); - - // 🆕 데이터 샘플 확인 - if (externalData && externalData.length > 0) { - console.log("🔶 첫 번째 데이터 샘플:", externalData[0]); - console.log("🔶 전체 데이터 개수:", externalData.length); - } - - // 🆕 필드 설정 확인 - if (initialFields && initialFields.length > 0) { - console.log("🔶 필드 설정:", initialFields); - } // ==================== 상태 ==================== const [fields, setFields] = useState(initialFields); @@ -384,20 +366,63 @@ export const PivotGridComponent: React.FC = ({ localStorage.setItem(stateStorageKey, JSON.stringify(stateToSave)); }, [fields, pivotState, sortConfig, columnWidths, stateStorageKey]); - // 상태 복원 (localStorage) + // 상태 복원 (localStorage) - 프로덕션 안전성 강화 useEffect(() => { if (typeof window === "undefined") return; - const savedState = localStorage.getItem(stateStorageKey); - if (savedState) { - try { - const parsed = JSON.parse(savedState); - if (parsed.fields) setFields(parsed.fields); - if (parsed.pivotState) setPivotState(parsed.pivotState); - if (parsed.sortConfig) setSortConfig(parsed.sortConfig); - if (parsed.columnWidths) setColumnWidths(parsed.columnWidths); - } catch (e) { - console.warn("피벗 상태 복원 실패:", e); + + try { + const savedState = localStorage.getItem(stateStorageKey); + if (!savedState) return; + + const parsed = JSON.parse(savedState); + + // 버전 체크 - 버전이 다르면 이전 상태 무시 + if (parsed.version !== PIVOT_STATE_VERSION) { + localStorage.removeItem(stateStorageKey); + return; } + + // 필드 복원 시 유효성 검사 (중요!) + if (parsed.fields && Array.isArray(parsed.fields) && parsed.fields.length > 0) { + // 저장된 필드가 현재 데이터와 호환되는지 확인 + const validFields = parsed.fields.filter((f: PivotFieldConfig) => + f && typeof f.field === "string" && typeof f.area === "string" + ); + + if (validFields.length > 0) { + setFields(validFields); + } + } + + // pivotState 복원 시 유효성 검사 (확장 경로 검증) + if (parsed.pivotState && typeof parsed.pivotState === "object") { + const restoredState: PivotGridState = { + // expandedRowPaths는 배열의 배열이어야 함 + expandedRowPaths: Array.isArray(parsed.pivotState.expandedRowPaths) + ? parsed.pivotState.expandedRowPaths.filter( + (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") + ) + : [], + // expandedColumnPaths도 동일하게 검증 + expandedColumnPaths: Array.isArray(parsed.pivotState.expandedColumnPaths) + ? parsed.pivotState.expandedColumnPaths.filter( + (p: unknown) => Array.isArray(p) && p.every(item => typeof item === "string") + ) + : [], + sortConfig: parsed.pivotState.sortConfig || null, + filterConfig: parsed.pivotState.filterConfig || {}, + }; + setPivotState(restoredState); + } + + if (parsed.sortConfig) setSortConfig(parsed.sortConfig); + if (parsed.columnWidths && typeof parsed.columnWidths === "object") { + setColumnWidths(parsed.columnWidths); + } + } catch (e) { + console.warn("피벗 상태 복원 실패, localStorage 초기화:", e); + // 손상된 상태는 제거 + localStorage.removeItem(stateStorageKey); } }, [stateStorageKey]); @@ -432,10 +457,12 @@ export const PivotGridComponent: React.FC = ({ // 필터 영역 필드 const filterFields = useMemo( - () => - fields + () => { + const result = fields .filter((f) => f.area === "filter" && f.visible !== false) - .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)), + .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); + return result; + }, [fields] ); @@ -480,80 +507,86 @@ export const PivotGridComponent: React.FC = ({ if (activeFilters.length === 0) return data; - return data.filter((row) => { + const result = data.filter((row) => { return activeFilters.every((filter) => { - const value = row[filter.field]; + const rawValue = row[filter.field]; const filterValues = filter.filterValues || []; const filterType = filter.filterType || "include"; + // 타입 안전한 비교: 값을 문자열로 변환하여 비교 + const value = rawValue === null || rawValue === undefined + ? "(빈 값)" + : String(rawValue); + if (filterType === "include") { - return filterValues.includes(value); + return filterValues.some((fv) => String(fv) === value); } else { - return !filterValues.includes(value); + return filterValues.every((fv) => String(fv) !== value); } }); }); + + // 모든 데이터가 필터링되면 경고 (디버깅용) + if (result.length === 0 && data.length > 0) { + console.warn("⚠️ [PivotGrid] 필터로 인해 모든 데이터가 제거됨"); + } + + return result; }, [data, fields]); // ==================== 피벗 처리 ==================== const pivotResult = useMemo(() => { - if (!filteredData || filteredData.length === 0 || fields.length === 0) { + try { + if (!filteredData || filteredData.length === 0 || fields.length === 0) { + return null; + } + + // FieldChooser에서 이미 필드를 완전히 제거하므로 visible 필터링 불필요 + // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) + if (fields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { + return null; + } + + const result = processPivotData( + filteredData, + fields, + pivotState.expandedRowPaths, + pivotState.expandedColumnPaths + ); + + return result; + } catch (error) { + console.error("❌ [pivotResult] 피벗 처리 에러:", error); return null; } - - const visibleFields = fields.filter((f) => f.visible !== false); - // 행, 열, 데이터 영역에 필드가 하나도 없으면 null 반환 (필터는 제외) - if (visibleFields.filter((f) => ["row", "column", "data"].includes(f.area)).length === 0) { - return null; - } - - const result = processPivotData( - filteredData, - visibleFields, - pivotState.expandedRowPaths, - pivotState.expandedColumnPaths - ); - - // 🆕 피벗 결과 확인 - console.log("🔶 피벗 처리 결과:", { - hasResult: !!result, - flatRowsCount: result?.flatRows?.length, - flatColumnsCount: result?.flatColumns?.length, - dataMatrixSize: result?.dataMatrix?.size, - expandedRowPaths: pivotState.expandedRowPaths.length, - expandedColumnPaths: pivotState.expandedColumnPaths.length, - }); - - return result; }, [filteredData, fields, pivotState.expandedRowPaths, pivotState.expandedColumnPaths]); - // 🆕 초기 로드 시 첫 레벨 자동 확장 + // 초기 로드 시 첫 레벨 자동 확장 useEffect(() => { - if (pivotResult && pivotResult.flatRows.length > 0) { - console.log("🔶 피벗 결과 생성됨:", { - flatRowsCount: pivotResult.flatRows.length, - expandedRowPaths: pivotState.expandedRowPaths.length, - isInitialExpanded, - }); + try { + if (pivotResult && pivotResult.flatRows && pivotResult.flatRows.length > 0 && !isInitialExpanded) { + // 첫 레벨 행들의 경로 수집 (level 0인 행들) + const firstLevelRows = pivotResult.flatRows.filter((row) => row.level === 0 && row.hasChildren); - // 첫 레벨 행들의 경로 수집 (level 0인 행들) - const firstLevelRows = pivotResult.flatRows.filter(row => row.level === 0 && row.hasChildren); - - console.log("🔶 첫 레벨 행 (level 0, hasChildren):", firstLevelRows.map(r => ({ path: r.path, caption: r.caption }))); - - // 초기 확장이 안 되어 있고, 첫 레벨 행이 있으면 자동 확장 - if (!isInitialExpanded && firstLevelRows.length > 0) { - const firstLevelPaths = firstLevelRows.map(row => row.path); - console.log("🔶 초기 자동 확장 실행:", firstLevelPaths); - setPivotState(prev => ({ - ...prev, - expandedRowPaths: firstLevelPaths, - })); - setIsInitialExpanded(true); + // 첫 레벨 행이 있으면 자동 확장 + if (firstLevelRows.length > 0 && firstLevelRows.length < 100) { + const firstLevelPaths = firstLevelRows.map((row) => row.path); + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: firstLevelPaths, + })); + setIsInitialExpanded(true); + } else { + // 행이 너무 많으면 자동 확장 건너뛰기 + setIsInitialExpanded(true); + } } + } catch (error) { + console.error("❌ [초기 확장] 에러:", error); + setIsInitialExpanded(true); } - }, [pivotResult, isInitialExpanded, pivotState.expandedRowPaths.length]); + }, [pivotResult, isInitialExpanded]); // 조건부 서식용 전체 값 수집 const allCellValues = useMemo(() => { @@ -718,8 +751,6 @@ export const PivotGridComponent: React.FC = ({ // 행 확장/축소 const handleToggleRowExpand = useCallback( (path: string[]) => { - console.log("🔶 행 확장/축소 클릭:", path); - setPivotState((prev) => { const pathKey = pathToKey(path); const existingIndex = prev.expandedRowPaths.findIndex( @@ -728,16 +759,13 @@ export const PivotGridComponent: React.FC = ({ let newPaths: string[][]; if (existingIndex >= 0) { - console.log("🔶 행 축소:", path); newPaths = prev.expandedRowPaths.filter( (_, i) => i !== existingIndex ); } else { - console.log("🔶 행 확장:", path); newPaths = [...prev.expandedRowPaths, path]; } - console.log("🔶 새로운 확장 경로:", newPaths); onExpandChange?.(newPaths); return { @@ -749,23 +777,52 @@ export const PivotGridComponent: React.FC = ({ [onExpandChange] ); - // 전체 확장 + // 전체 확장 (재귀적으로 모든 레벨 확장) const handleExpandAll = useCallback(() => { - if (!pivotResult) return; - - const allRowPaths: string[][] = []; - pivotResult.flatRows.forEach((row) => { - if (row.hasChildren) { - allRowPaths.push(row.path); + try { + if (!pivotResult) { + return; } - }); - setPivotState((prev) => ({ - ...prev, - expandedRowPaths: allRowPaths, - expandedColumnPaths: [], - })); - }, [pivotResult]); + // 재귀적으로 모든 가능한 경로 생성 + const allRowPaths: string[][] = []; + const rowFields = fields.filter((f) => f.area === "row" && f.visible !== false); + + // 행 필드가 없으면 종료 + if (rowFields.length === 0) { + return; + } + + // 데이터에서 모든 고유한 경로 추출 + const pathSet = new Set(); + filteredData.forEach((item) => { + // 마지막 레벨은 제외 (확장할 자식이 없으므로) + for (let depth = 1; depth < rowFields.length; depth++) { + const path = rowFields.slice(0, depth).map((f) => String(item[f.field] ?? "")); + const pathKey = JSON.stringify(path); + pathSet.add(pathKey); + } + }); + + // Set을 배열로 변환 (최대 1000개로 제한하여 성능 보호) + const MAX_PATHS = 1000; + let count = 0; + pathSet.forEach((pathKey) => { + if (count < MAX_PATHS) { + allRowPaths.push(JSON.parse(pathKey)); + count++; + } + }); + + setPivotState((prev) => ({ + ...prev, + expandedRowPaths: allRowPaths, + expandedColumnPaths: [], + })); + } catch (error) { + console.error("❌ [handleExpandAll] 에러:", error); + } + }, [pivotResult, fields, filteredData]); // 전체 축소 const handleCollapseAll = useCallback(() => { @@ -888,6 +945,8 @@ export const PivotGridComponent: React.FC = ({ // 인쇄 기능 (PDF 내보내기보다 먼저 정의해야 함) const handlePrint = useCallback(() => { + if (typeof window === "undefined") return; + const printContent = tableRef.current; if (!printContent) return; @@ -988,10 +1047,14 @@ export const PivotGridComponent: React.FC = ({ console.log("피벗 상태가 저장되었습니다."); }, [saveStateToStorage]); - // 상태 초기화 + // 상태 초기화 (확장/축소, 정렬, 필터만 초기화, 필드 설정은 유지) const handleResetState = useCallback(() => { - localStorage.removeItem(stateStorageKey); - setFields(initialFields); + // 로컬 스토리지에서 상태 제거 (SSR 보호) + if (typeof window !== "undefined") { + localStorage.removeItem(stateStorageKey); + } + + // 확장/축소, 정렬, 필터 상태만 초기화 setPivotState({ expandedRowPaths: [], expandedColumnPaths: [], @@ -1002,7 +1065,7 @@ export const PivotGridComponent: React.FC = ({ setColumnWidths({}); setSelectedCell(null); setSelectionRange(null); - }, [stateStorageKey, initialFields]); + }, [stateStorageKey]); // 필드 숨기기/표시 상태 const [hiddenFields, setHiddenFields] = useState>(new Set()); @@ -1019,11 +1082,6 @@ export const PivotGridComponent: React.FC = ({ }); }, []); - // 숨겨진 필드 제외한 활성 필드들 - const visibleFields = useMemo(() => { - return fields.filter((f) => !hiddenFields.has(f.field)); - }, [fields, hiddenFields]); - // 숨겨진 필드 목록 const hiddenFieldsList = useMemo(() => { return fields.filter((f) => hiddenFields.has(f.field)); @@ -1391,8 +1449,8 @@ export const PivotGridComponent: React.FC = ({ variant="ghost" size="sm" className="h-7 px-2" - onClick={handleExpandAll} - title="전체 확장" + onClick={handleCollapseAll} + title="전체 축소" > @@ -1401,8 +1459,8 @@ export const PivotGridComponent: React.FC = ({ variant="ghost" size="sm" className="h-7 px-2" - onClick={handleCollapseAll} - title="전체 축소" + onClick={handleExpandAll} + title="전체 확장" > @@ -1582,19 +1640,25 @@ export const PivotGridComponent: React.FC = ({ } /> @@ -1608,7 +1672,14 @@ export const PivotGridComponent: React.FC = ({
0 ? containerHeight : undefined, + // 최소 200px 보장 + 데이터에 맞게 조정 (최대 400px) + minHeight: Math.max( + 200, // 절대 최소값 - 블라인드 효과 방지 + Math.min(400, (sortedFlatRows.length + 3) * ROW_HEIGHT + 50) + ) + }} tabIndex={0} onKeyDown={handleKeyDown} > @@ -1883,12 +1954,15 @@ export const PivotGridComponent: React.FC = ({ }); })()} - {/* 가상 스크롤 하단 여백 */} - {enableVirtualScroll && ( - - - - )} + {/* 가상 스크롤 하단 여백 - 음수 방지 */} + {enableVirtualScroll && (() => { + const bottomPadding = Math.max(0, virtualScroll.totalHeight - virtualScroll.offsetTop - (visibleFlatRows.length * ROW_HEIGHT)); + return bottomPadding > 0 ? ( + + + + ) : null; + })()} {/* 열 총계 행 (하단 위치 - 기본값) */} {totals?.showColumnGrandTotals && totals?.rowGrandTotalPosition !== "top" && ( diff --git a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx index 191f3610..61ebacd7 100644 --- a/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx +++ b/frontend/lib/registry/components/pivot-grid/PivotGridRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, Component, ErrorInfo, ReactNode } from "react"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; import { ComponentCategory } from "@/types/component"; @@ -8,6 +8,66 @@ import { PivotGridComponent } from "./PivotGridComponent"; import { PivotGridConfigPanel } from "./PivotGridConfigPanel"; import { PivotFieldConfig } from "./types"; import { dataApi } from "@/lib/api/data"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +// ==================== 에러 경계 ==================== + +interface ErrorBoundaryState { + hasError: boolean; + error?: Error; +} + +class PivotGridErrorBoundary extends Component< + { children: ReactNode; onReset?: () => void }, + ErrorBoundaryState +> { + constructor(props: { children: ReactNode; onReset?: () => void }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("🔴 [PivotGrid] 렌더링 에러:", error); + console.error("🔴 [PivotGrid] 에러 정보:", errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: undefined }); + this.props.onReset?.(); + }; + + render() { + if (this.state.hasError) { + return ( +
+ +

+ 피벗 그리드 오류 +

+

+ {this.state.error?.message || "알 수 없는 오류가 발생했습니다."} +

+ +
+ ); + } + + return this.props.children; + } +} // ==================== 샘플 데이터 (미리보기용) ==================== @@ -111,19 +171,14 @@ const PivotGridWrapper: React.FC = (props) => { setIsLoading(true); try { - console.log("🔷 [PivotGrid] 테이블 데이터 로딩 시작:", tableName); - const response = await dataApi.getTableData(tableName, { page: 1, - size: 10000, // 피벗 분석용 대량 데이터 (pageSize → size) + size: 10000, // 피벗 분석용 대량 데이터 }); - console.log("🔷 [PivotGrid] API 응답:", response); - // dataApi.getTableData는 { data, total, page, size, totalPages } 구조 if (response.data && Array.isArray(response.data)) { setLoadedData(response.data); - console.log("✅ [PivotGrid] 데이터 로딩 완료:", response.data.length, "건"); } else { console.error("❌ [PivotGrid] 데이터 로딩 실패: 응답에 data 배열이 없음"); setLoadedData([]); @@ -137,21 +192,6 @@ const PivotGridWrapper: React.FC = (props) => { loadTableData(); }, [componentConfig.dataSource?.tableName, configData, props.isDesignMode]); - - // 디버깅 로그 - console.log("🔷 PivotGridWrapper props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasLoadedData: loadedData.length > 0, - loadedDataLength: loadedData.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - isLoading, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -173,13 +213,6 @@ const PivotGridWrapper: React.FC = (props) => { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridWrapper final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, @@ -200,24 +233,27 @@ const PivotGridWrapper: React.FC = (props) => { ); } + // 에러 경계로 감싸서 렌더링 에러 시 컴포넌트가 완전히 사라지지 않도록 함 return ( - + + + ); }; @@ -283,18 +319,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { const componentConfig = props.componentConfig || props.config || {}; const configFields = componentConfig.fields || props.fields; const configData = props.data; - - // 디버깅 로그 - console.log("🔷 PivotGridRenderer props:", { - isDesignMode: props.isDesignMode, - isInteractive: props.isInteractive, - hasComponentConfig: !!props.componentConfig, - hasConfig: !!props.config, - hasData: !!configData, - dataLength: configData?.length, - hasFields: !!configFields, - fieldsLength: configFields?.length, - }); // 디자인 모드 판단: // 1. isDesignMode === true @@ -314,13 +338,6 @@ export class PivotGridRenderer extends AutoRegisteringComponentRenderer { ? (componentConfig.title || props.title || "피벗 그리드") + " (미리보기)" : (componentConfig.title || props.title); - console.log("🔷 PivotGridRenderer final:", { - isDesignMode, - usePreviewData, - finalDataLength: finalData?.length, - finalFieldsLength: finalFields?.length, - }); - // 총계 설정 const totalsConfig = componentConfig.totals || props.totals || { showRowGrandTotals: true, diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx index 89fe5128..fba64e65 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldChooser.tsx @@ -267,11 +267,9 @@ export const FieldChooser: React.FC = ({ const existingConfig = selectedFields.find((f) => f.field === field.field); if (area === "none") { - // 필드 제거 또는 숨기기 + // 필드 완전 제거 (visible: false 대신 배열에서 제거) if (existingConfig) { - const newFields = selectedFields.map((f) => - f.field === field.field ? { ...f, visible: false } : f - ); + const newFields = selectedFields.filter((f) => f.field !== field.field); onFieldsChange(newFields); } } else { diff --git a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx index fed43afb..967afd08 100644 --- a/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx +++ b/frontend/lib/registry/components/pivot-grid/components/FieldPanel.tsx @@ -25,6 +25,7 @@ import { horizontalListSortingStrategy, useSortable, } from "@dnd-kit/sortable"; +import { useDroppable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; import { cn } from "@/lib/utils"; import { PivotFieldConfig, PivotAreaType } from "../types"; @@ -244,22 +245,31 @@ const DroppableArea: React.FC = ({ const areaFields = fields.filter((f) => f.area === area && f.visible !== false); const fieldIds = areaFields.map((f) => `${area}-${f.field}`); + // 🆕 드롭 가능 영역 설정 + const { setNodeRef, isOver: isOverDroppable } = useDroppable({ + id: area, // "filter", "column", "row", "data" + }); + + const finalIsOver = isOver || isOverDroppable; + return (
{/* 영역 헤더 */} -
+
{icon} {title} {areaFields.length > 0 && ( - + {areaFields.length} )} @@ -267,11 +277,16 @@ const DroppableArea: React.FC = ({ {/* 필드 목록 */} -
+
{areaFields.length === 0 ? ( - - 필드를 여기로 드래그 - +
+ + ← 필드를 여기로 드래그하세요 + +
) : ( areaFields.map((field) => ( = ({ return; } - // 드롭 영역 감지 + // 드롭 영역 감지 (영역 자체의 ID를 우선 확인) const overId = over.id as string; + + // 1. overId가 영역 자체인 경우 (filter, column, row, data) + if (["filter", "column", "row", "data"].includes(overId)) { + setOverArea(overId as PivotAreaType); + return; + } + + // 2. overId가 필드인 경우 (예: row-part_name) const targetArea = overId.split("-")[0] as PivotAreaType; if (["filter", "column", "row", "data"].includes(targetArea)) { setOverArea(targetArea); @@ -350,10 +373,13 @@ export const FieldPanel: React.FC = ({ // 드래그 종료 const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; + const currentOverArea = overArea; // handleDragOver에서 감지한 영역 저장 setActiveId(null); setOverArea(null); - if (!over) return; + if (!over) { + return; + } const activeId = active.id as string; const overId = over.id as string; @@ -363,7 +389,16 @@ export const FieldPanel: React.FC = ({ PivotAreaType, string ]; - const [targetArea] = overId.split("-") as [PivotAreaType, string]; + + // targetArea 결정: handleDragOver에서 감지한 영역 우선 사용 + let targetArea: PivotAreaType; + if (currentOverArea) { + targetArea = currentOverArea; + } else if (["filter", "column", "row", "data"].includes(overId)) { + targetArea = overId as PivotAreaType; + } else { + targetArea = overId.split("-")[0] as PivotAreaType; + } // 같은 영역 내 정렬 if (sourceArea === targetArea) { @@ -406,6 +441,7 @@ export const FieldPanel: React.FC = ({ } return f; }); + onFieldsChange(newFields); } }; diff --git a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts index 152cb2df..6557dee3 100644 --- a/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts +++ b/frontend/lib/registry/components/pivot-grid/hooks/useVirtualScroll.ts @@ -51,14 +51,18 @@ export function useVirtualScroll(options: VirtualScrollOptions): VirtualScrollRe // 보이는 아이템 수 const visibleCount = Math.ceil(containerHeight / itemHeight); - // 시작/끝 인덱스 계산 + // 시작/끝 인덱스 계산 (음수 방지) const { startIndex, endIndex } = useMemo(() => { + // itemCount가 0이면 빈 배열 + if (itemCount === 0) { + return { startIndex: 0, endIndex: -1 }; + } const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); const end = Math.min( itemCount - 1, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan ); - return { startIndex: start, endIndex: end }; + return { startIndex: start, endIndex: Math.max(start, end) }; // end가 start보다 작지 않도록 }, [scrollTop, itemHeight, containerHeight, itemCount, overscan]); // 전체 높이 diff --git a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts index 4d3fecfd..02dd4608 100644 --- a/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts +++ b/frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts @@ -710,27 +710,19 @@ export function processPivotData( .filter((f) => f.area === "data" && f.visible !== false) .sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0)); - const filterFields = fields.filter( - (f) => f.area === "filter" && f.visible !== false + // 참고: 필터링은 PivotGridComponent에서 이미 처리됨 + // 여기서는 추가 필터링 없이 전달받은 데이터 사용 + const filteredData = data; + + // 확장 경로 Set 변환 (잘못된 형식 필터링) + const validRowPaths = (expandedRowPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") ); - - // 필터 적용 - let filteredData = data; - for (const filterField of filterFields) { - if (filterField.filterValues && filterField.filterValues.length > 0) { - filteredData = filteredData.filter((row) => { - const value = getFieldValue(row, filterField); - if (filterField.filterType === "exclude") { - return !filterField.filterValues!.includes(value); - } - return filterField.filterValues!.includes(value); - }); - } - } - - // 확장 경로 Set 변환 - const expandedRowSet = new Set(expandedRowPaths.map(pathToKey)); - const expandedColSet = new Set(expandedColumnPaths.map(pathToKey)); + const validColPaths = (expandedColumnPaths || []).filter( + (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") + ); + const expandedRowSet = new Set(validRowPaths.map(pathToKey)); + const expandedColSet = new Set(validColPaths.map(pathToKey)); // 기본 확장: 첫 번째 레벨 모두 확장 if (expandedRowPaths.length === 0 && rowFields.length > 0) { diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 78abf111..366aa05b 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -41,7 +41,7 @@ import { Lock, } from "lucide-react"; import * as XLSX from "xlsx"; -import { FileText, ChevronRightIcon } from "lucide-react"; +import { FileText, ChevronRightIcon, Search } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; @@ -455,6 +455,7 @@ export const TableListComponent: React.FC = ({ // 🆕 컬럼 헤더 필터 상태 (상단에서 선언) const [headerFilters, setHeaderFilters] = useState>>({}); + const [headerLikeFilters, setHeaderLikeFilters] = useState>({}); // LIKE 검색용 const [openFilterColumn, setOpenFilterColumn] = useState(null); // 🆕 Filter Builder (고급 필터) 관련 상태 - filteredData보다 먼저 정의해야 함 @@ -488,6 +489,22 @@ export const TableListComponent: React.FC = ({ }); } + // 2-1. 🆕 LIKE 검색 필터 적용 + if (Object.keys(headerLikeFilters).length > 0) { + result = result.filter((row) => { + return Object.entries(headerLikeFilters).every(([columnName, searchText]) => { + if (!searchText || searchText.trim() === "") return true; + + // 여러 가능한 컬럼명 시도 + const cellValue = row[columnName] ?? row[columnName.toLowerCase()] ?? row[columnName.toUpperCase()]; + const cellStr = cellValue !== null && cellValue !== undefined ? String(cellValue).toLowerCase() : ""; + + // LIKE 검색 (대소문자 무시) + return cellStr.includes(searchText.toLowerCase()); + }); + }); + } + // 3. 🆕 Filter Builder 적용 if (filterGroups.length > 0) { result = result.filter((row) => { @@ -541,7 +558,7 @@ export const TableListComponent: React.FC = ({ } return result; - }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, filterGroups]); + }, [data, splitPanelPosition, splitPanelContext?.addedItemIds, headerFilters, headerLikeFilters, filterGroups]); const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(0); @@ -2935,6 +2952,7 @@ export const TableListComponent: React.FC = ({ headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), ), + headerLikeFilters, // LIKE 검색 필터 저장 pageSize: localPageSize, timestamp: Date.now(), }; @@ -2955,6 +2973,7 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, showGridLines, headerFilters, + headerLikeFilters, localPageSize, ]); @@ -2991,6 +3010,9 @@ export const TableListComponent: React.FC = ({ }); setHeaderFilters(filters); } + if (state.headerLikeFilters) { + setHeaderLikeFilters(state.headerLikeFilters); + } } catch (error) { console.error("❌ 테이블 상태 복원 실패:", error); } @@ -5737,7 +5759,7 @@ export const TableListComponent: React.FC = ({ }} className={cn( "hover:bg-primary/20 ml-1 rounded p-0.5 transition-colors", - headerFilters[column.columnName]?.size > 0 && "text-primary bg-primary/10", + (headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && "text-primary bg-primary/10", )} title="필터" > @@ -5745,7 +5767,7 @@ export const TableListComponent: React.FC = ({ e.stopPropagation()} > @@ -5754,16 +5776,42 @@ export const TableListComponent: React.FC = ({ 필터: {columnLabels[column.columnName] || column.displayName} - {headerFilters[column.columnName]?.size > 0 && ( + {(headerFilters[column.columnName]?.size > 0 || headerLikeFilters[column.columnName]) && ( )}
-
+ {/* LIKE 검색 입력 필드 */} +
+ + { + setHeaderLikeFilters((prev) => ({ + ...prev, + [column.columnName]: e.target.value, + })); + }} + className="border-input bg-background placeholder:text-muted-foreground h-7 w-full rounded-md border pl-7 pr-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary" + onClick={(e) => e.stopPropagation()} + /> +
+ {/* 구분선 */} +
또는 값 선택:
+
{columnUniqueValues[column.columnName]?.slice(0, 50).map((val) => { const isSelected = headerFilters[column.columnName]?.has(val); return (