diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 652677ca..e928f96c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자 import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리 import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리 import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리 +import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -255,6 +256,7 @@ app.use("/api/cascading-auto-fill", cascadingAutoFillRoutes); // 자동 입력 app.use("/api/cascading-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리 app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리 app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리 +app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index c8e8ce82..231a7cdc 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3394,13 +3394,23 @@ export async function copyMenu( } : undefined; + // 추가 복사 옵션 (카테고리, 코드, 채번규칙 등) + const additionalCopyOptions = req.body.additionalCopyOptions + ? { + copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true, + copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true, + copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true, + } + : undefined; + // 메뉴 복사 실행 const menuCopyService = new MenuCopyService(); const result = await menuCopyService.copyMenu( parseInt(menuObjid, 10), targetCompanyCode, userId, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); logger.info("✅ 메뉴 복사 API 성공"); diff --git a/backend-node/src/controllers/cascadingRelationController.ts b/backend-node/src/controllers/cascadingRelationController.ts index 27f03c71..c40c6aa5 100644 --- a/backend-node/src/controllers/cascadingRelationController.ts +++ b/backend-node/src/controllers/cascadingRelationController.ts @@ -662,6 +662,10 @@ export const getParentOptions = async ( /** * 연쇄 관계로 자식 옵션 조회 * 실제 연쇄 드롭다운에서 사용하는 API + * + * 다중 부모값 지원: + * - parentValue: 단일 값 (예: "공정검사") + * - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열) */ export const getCascadingOptions = async ( req: AuthenticatedRequest, @@ -669,10 +673,26 @@ export const getCascadingOptions = async ( ) => { try { const { code } = req.params; - const { parentValue } = req.query; + const { parentValue, parentValues } = req.query; const companyCode = req.user?.companyCode || "*"; - if (!parentValue) { + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + // parentValues가 있으면 우선 사용 (다중 선택) + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + // 콤마로 구분된 문자열 + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + // 기존 단일 값 호환 + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { return res.json({ success: true, data: [], @@ -714,13 +734,17 @@ export const getCascadingOptions = async ( const relation = relationResult.rows[0]; - // 자식 옵션 조회 + // 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용 + // SQL Injection 방지를 위해 파라미터화된 쿼리 사용 + const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', '); + let optionsQuery = ` - SELECT + SELECT DISTINCT ${relation.child_value_column} as value, - ${relation.child_label_column} as label + ${relation.child_label_column} as label, + ${relation.child_filter_column} as parent_value FROM ${relation.child_table} - WHERE ${relation.child_filter_column} = $1 + WHERE ${relation.child_filter_column} IN (${placeholders}) `; // 멀티테넌시 적용 (테이블에 company_code가 있는 경우) @@ -730,7 +754,8 @@ export const getCascadingOptions = async ( [relation.child_table] ); - const optionsParams: any[] = [parentValue]; + const optionsParams: any[] = [...parentValueArray]; + let paramIndex = parentValueArray.length + 1; // company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만 if ( @@ -738,8 +763,9 @@ export const getCascadingOptions = async ( tableInfoResult.rowCount > 0 && companyCode !== "*" ) { - optionsQuery += ` AND company_code = $2`; + optionsQuery += ` AND company_code = $${paramIndex}`; optionsParams.push(companyCode); + paramIndex++; } // 정렬 @@ -751,9 +777,9 @@ export const getCascadingOptions = async ( const optionsResult = await pool.query(optionsQuery, optionsParams); - logger.info("연쇄 옵션 조회", { + logger.info("연쇄 옵션 조회 (다중 부모값 지원)", { relationCode: code, - parentValue, + parentValues: parentValueArray, optionsCount: optionsResult.rowCount, }); diff --git a/backend-node/src/controllers/categoryValueCascadingController.ts b/backend-node/src/controllers/categoryValueCascadingController.ts new file mode 100644 index 00000000..41ac330e --- /dev/null +++ b/backend-node/src/controllers/categoryValueCascadingController.ts @@ -0,0 +1,927 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +const pool = getPool(); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +/** + * 카테고리 값 연쇄관계 그룹 목록 조회 + */ +export const getCategoryValueCascadingGroups = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const { isActive } = req.query; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date, + updated_by, + updated_date + FROM category_value_cascading_group + WHERE 1=1 + `; + + const params: any[] = []; + let paramIndex = 1; + + // 멀티테넌시 필터링 + if (companyCode !== "*") { + query += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + params.push(companyCode); + paramIndex++; + } + + if (isActive !== undefined) { + query += ` AND is_active = $${paramIndex}`; + params.push(isActive); + paramIndex++; + } + + query += ` ORDER BY relation_name ASC`; + + const result = await pool.query(query, params); + + logger.info("카테고리 값 연쇄관계 그룹 목록 조회", { + companyCode, + count: result.rowCount, + }); + + return res.json({ + success: true, + data: result.rows, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 목록 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 목록 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 상세 조회 + */ +export const getCategoryValueCascadingGroupById = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 그룹 정보 조회 + let groupQuery = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE group_id = $1 + `; + + const groupParams: any[] = [groupId]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 매핑 정보 조회 + const mappingQuery = ` + SELECT + mapping_id, + parent_value_code, + parent_value_label, + child_value_code, + child_value_label, + display_order, + is_active + FROM category_value_cascading_mapping + WHERE group_id = $1 AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const mappingResult = await pool.query(mappingQuery, [groupId]); + + // 부모 값별로 자식 값 그룹화 + const mappingsByParent: Record = {}; + for (const row of mappingResult.rows) { + const parentKey = row.parent_value_code; + if (!mappingsByParent[parentKey]) { + mappingsByParent[parentKey] = []; + } + mappingsByParent[parentKey].push({ + childValueCode: row.child_value_code, + childValueLabel: row.child_value_label, + displayOrder: row.display_order, + }); + } + + return res.json({ + success: true, + data: { + ...groupResult.rows[0], + mappings: mappingResult.rows, + mappingsByParent, + }, + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 상세 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 관계 코드로 조회 + */ +export const getCategoryValueCascadingByCode = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + let query = ` + SELECT + group_id, + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const params: any[] = [code]; + + if (companyCode !== "*") { + query += ` AND (company_code = $2 OR company_code = '*')`; + params.push(companyCode); + } + + query += ` LIMIT 1`; + + const result = await pool.query(query, params); + + if (result.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + return res.json({ + success: true, + data: result.rows[0], + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 코드 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 생성 + */ +export const createCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationCode, + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange = true, + showGroupLabel = true, + emptyParentMessage, + noOptionsMessage, + } = req.body; + + // 필수 필드 검증 + if (!relationCode || !relationName || !parentTableName || !parentColumnName || !childTableName || !childColumnName) { + return res.status(400).json({ + success: false, + message: "필수 필드가 누락되었습니다.", + }); + } + + // 중복 코드 체크 + const duplicateCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group + WHERE relation_code = $1 AND (company_code = $2 OR company_code = '*')`, + [relationCode, companyCode] + ); + + if (duplicateCheck.rowCount && duplicateCheck.rowCount > 0) { + return res.status(400).json({ + success: false, + message: "이미 존재하는 관계 코드입니다.", + }); + } + + const query = ` + INSERT INTO category_value_cascading_group ( + relation_code, + relation_name, + description, + parent_table_name, + parent_column_name, + parent_menu_objid, + child_table_name, + child_column_name, + child_menu_objid, + clear_on_parent_change, + show_group_label, + empty_parent_message, + no_options_message, + company_code, + is_active, + created_by, + created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, 'Y', $15, NOW()) + RETURNING * + `; + + const result = await pool.query(query, [ + relationCode, + relationName, + description || null, + parentTableName, + parentColumnName, + parentMenuObjid || null, + childTableName, + childColumnName, + childMenuObjid || null, + clearOnParentChange ? "Y" : "N", + showGroupLabel ? "Y" : "N", + emptyParentMessage || "상위 항목을 먼저 선택하세요", + noOptionsMessage || "선택 가능한 항목이 없습니다", + companyCode, + userId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 생성", { + groupId: result.rows[0].group_id, + relationCode, + companyCode, + userId, + }); + + return res.status(201).json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 생성되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 생성 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 생성에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 수정 + */ +export const updateCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + const { + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange, + showGroupLabel, + emptyParentMessage, + noOptionsMessage, + isActive, + } = req.body; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "수정 권한이 없습니다.", + }); + } + + const query = ` + UPDATE category_value_cascading_group SET + relation_name = COALESCE($1, relation_name), + description = COALESCE($2, description), + parent_table_name = COALESCE($3, parent_table_name), + parent_column_name = COALESCE($4, parent_column_name), + parent_menu_objid = COALESCE($5, parent_menu_objid), + child_table_name = COALESCE($6, child_table_name), + child_column_name = COALESCE($7, child_column_name), + child_menu_objid = COALESCE($8, child_menu_objid), + clear_on_parent_change = COALESCE($9, clear_on_parent_change), + show_group_label = COALESCE($10, show_group_label), + empty_parent_message = COALESCE($11, empty_parent_message), + no_options_message = COALESCE($12, no_options_message), + is_active = COALESCE($13, is_active), + updated_by = $14, + updated_date = NOW() + WHERE group_id = $15 + RETURNING * + `; + + const result = await pool.query(query, [ + relationName, + description, + parentTableName, + parentColumnName, + parentMenuObjid, + childTableName, + childColumnName, + childMenuObjid, + clearOnParentChange !== undefined ? (clearOnParentChange ? "Y" : "N") : null, + showGroupLabel !== undefined ? (showGroupLabel ? "Y" : "N") : null, + emptyParentMessage, + noOptionsMessage, + isActive !== undefined ? (isActive ? "Y" : "N") : null, + userId, + groupId, + ]); + + logger.info("카테고리 값 연쇄관계 그룹 수정", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + data: result.rows[0], + message: "카테고리 값 연쇄관계 그룹이 수정되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 수정 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 수정에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 삭제 + */ +export const deleteCategoryValueCascadingGroup = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const userId = req.user?.userId || "system"; + + // 권한 체크 + const existingCheck = await pool.query( + `SELECT group_id, company_code FROM category_value_cascading_group WHERE group_id = $1`, + [groupId] + ); + + if (existingCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + const existingCompanyCode = existingCheck.rows[0].company_code; + if (companyCode !== "*" && existingCompanyCode !== companyCode && existingCompanyCode !== "*") { + return res.status(403).json({ + success: false, + message: "삭제 권한이 없습니다.", + }); + } + + // 소프트 삭제 + await pool.query( + `UPDATE category_value_cascading_group + SET is_active = 'N', updated_by = $1, updated_date = NOW() + WHERE group_id = $2`, + [userId, groupId] + ); + + logger.info("카테고리 값 연쇄관계 그룹 삭제", { + groupId, + companyCode, + userId, + }); + + return res.json({ + success: true, + message: "카테고리 값 연쇄관계 그룹이 삭제되었습니다.", + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 그룹 삭제 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹 삭제에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 카테고리 값 연쇄관계 매핑 CRUD +// ============================================ + +/** + * 매핑 일괄 저장 (기존 매핑 교체) + */ +export const saveCategoryValueCascadingMappings = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { groupId } = req.params; + const companyCode = req.user?.companyCode || "*"; + const { mappings } = req.body; // [{ parentValueCode, parentValueLabel, childValueCode, childValueLabel, displayOrder }] + + if (!Array.isArray(mappings)) { + return res.status(400).json({ + success: false, + message: "mappings는 배열이어야 합니다.", + }); + } + + // 그룹 존재 확인 + const groupCheck = await pool.query( + `SELECT group_id FROM category_value_cascading_group WHERE group_id = $1 AND is_active = 'Y'`, + [groupId] + ); + + if (groupCheck.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계 그룹을 찾을 수 없습니다.", + }); + } + + // 트랜잭션으로 처리 + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 기존 매핑 삭제 (하드 삭제) + await client.query( + `DELETE FROM category_value_cascading_mapping WHERE group_id = $1`, + [groupId] + ); + + // 새 매핑 삽입 + if (mappings.length > 0) { + const insertQuery = ` + INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'Y', NOW()) + `; + + for (const mapping of mappings) { + await client.query(insertQuery, [ + groupId, + mapping.parentValueCode, + mapping.parentValueLabel || null, + mapping.childValueCode, + mapping.childValueLabel || null, + mapping.displayOrder || 0, + companyCode, + ]); + } + } + + await client.query("COMMIT"); + + logger.info("카테고리 값 연쇄관계 매핑 저장", { + groupId, + mappingCount: mappings.length, + companyCode, + }); + + return res.json({ + success: true, + message: `${mappings.length}개의 매핑이 저장되었습니다.`, + }); + } catch (err) { + await client.query("ROLLBACK"); + throw err; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("카테고리 값 연쇄관계 매핑 저장 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄관계 매핑 저장에 실패했습니다.", + error: error.message, + }); + } +}; + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +/** + * 카테고리 값 연쇄 옵션 조회 + * 부모 값(들)에 해당하는 자식 카테고리 값 목록 반환 + * 다중 부모값 지원 + */ +export const getCategoryValueCascadingOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const { parentValue, parentValues } = req.query; + const companyCode = req.user?.companyCode || "*"; + + // 다중 부모값 파싱 + let parentValueArray: string[] = []; + + if (parentValues) { + if (Array.isArray(parentValues)) { + parentValueArray = parentValues.map(v => String(v)); + } else { + parentValueArray = String(parentValues).split(',').map(v => v.trim()).filter(v => v); + } + } else if (parentValue) { + parentValueArray = [String(parentValue)]; + } + + if (parentValueArray.length === 0) { + return res.json({ + success: true, + data: [], + message: "부모 값이 없습니다.", + }); + } + + // 관계 정보 조회 + let groupQuery = ` + SELECT group_id, show_group_label + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 매핑된 자식 값 조회 (다중 부모값에 대해 IN 절 사용) + const placeholders = parentValueArray.map((_, idx) => `$${idx + 2}`).join(', '); + + const optionsQuery = ` + SELECT DISTINCT + child_value_code as value, + child_value_label as label, + parent_value_code as parent_value, + parent_value_label as parent_label, + display_order + FROM category_value_cascading_mapping + WHERE group_id = $1 + AND parent_value_code IN (${placeholders}) + AND is_active = 'Y' + ORDER BY parent_value_code, display_order, child_value_label + `; + + const optionsResult = await pool.query(optionsQuery, [group.group_id, ...parentValueArray]); + + logger.info("카테고리 값 연쇄 옵션 조회", { + relationCode: code, + parentValues: parentValueArray, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + showGroupLabel: group.show_group_label === 'Y', + }); + } catch (error: any) { + logger.error("카테고리 값 연쇄 옵션 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "카테고리 값 연쇄 옵션 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 부모 카테고리 값 목록 조회 + */ +export const getCategoryValueCascadingParentOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + parent_table_name, + parent_column_name, + parent_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 부모 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [group.parent_table_name, group.parent_column_name]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.parent_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.parent_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("부모 카테고리 값 조회", { + relationCode: code, + tableName: group.parent_table_name, + columnName: group.parent_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("부모 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "부모 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + +/** + * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) + */ +export const getCategoryValueCascadingChildOptions = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { code } = req.params; + const companyCode = req.user?.companyCode || "*"; + + // 관계 정보 조회 + let groupQuery = ` + SELECT + group_id, + child_table_name, + child_column_name, + child_menu_objid + FROM category_value_cascading_group + WHERE relation_code = $1 AND is_active = 'Y' + `; + + const groupParams: any[] = [code]; + + if (companyCode !== "*") { + groupQuery += ` AND (company_code = $2 OR company_code = '*')`; + groupParams.push(companyCode); + } + + groupQuery += ` LIMIT 1`; + + const groupResult = await pool.query(groupQuery, groupParams); + + if (groupResult.rowCount === 0) { + return res.status(404).json({ + success: false, + message: "카테고리 값 연쇄관계를 찾을 수 없습니다.", + }); + } + + const group = groupResult.rows[0]; + + // 자식 카테고리 값 조회 (table_column_category_values에서) + let optionsQuery = ` + SELECT + value_code as value, + value_label as label, + value_order as display_order + FROM table_column_category_values + WHERE table_name = $1 + AND column_name = $2 + AND is_active = true + `; + + const optionsParams: any[] = [group.child_table_name, group.child_column_name]; + let paramIndex = 3; + + // 메뉴 스코프 적용 + if (group.child_menu_objid) { + optionsQuery += ` AND menu_objid = $${paramIndex}`; + optionsParams.push(group.child_menu_objid); + paramIndex++; + } + + // 멀티테넌시 적용 + if (companyCode !== "*") { + optionsQuery += ` AND (company_code = $${paramIndex} OR company_code = '*')`; + optionsParams.push(companyCode); + } + + optionsQuery += ` ORDER BY value_order, value_label`; + + const optionsResult = await pool.query(optionsQuery, optionsParams); + + logger.info("자식 카테고리 값 조회", { + relationCode: code, + tableName: group.child_table_name, + columnName: group.child_column_name, + optionsCount: optionsResult.rowCount, + }); + + return res.json({ + success: true, + data: optionsResult.rows, + }); + } catch (error: any) { + logger.error("자식 카테고리 값 조회 실패", { error: error.message }); + return res.status(500).json({ + success: false, + message: "자식 카테고리 값 조회에 실패했습니다.", + error: error.message, + }); + } +}; + diff --git a/backend-node/src/routes/categoryValueCascadingRoutes.ts b/backend-node/src/routes/categoryValueCascadingRoutes.ts new file mode 100644 index 00000000..d8919627 --- /dev/null +++ b/backend-node/src/routes/categoryValueCascadingRoutes.ts @@ -0,0 +1,64 @@ +import { Router } from "express"; +import { + getCategoryValueCascadingGroups, + getCategoryValueCascadingGroupById, + getCategoryValueCascadingByCode, + createCategoryValueCascadingGroup, + updateCategoryValueCascadingGroup, + deleteCategoryValueCascadingGroup, + saveCategoryValueCascadingMappings, + getCategoryValueCascadingOptions, + getCategoryValueCascadingParentOptions, + getCategoryValueCascadingChildOptions, +} from "../controllers/categoryValueCascadingController"; +import { authenticateToken } from "../middleware/authMiddleware"; + +const router = Router(); + +// 모든 라우트에 인증 적용 +router.use(authenticateToken); + +// ============================================ +// 카테고리 값 연쇄관계 그룹 CRUD +// ============================================ + +// 그룹 목록 조회 +router.get("/groups", getCategoryValueCascadingGroups); + +// 그룹 상세 조회 (ID) +router.get("/groups/:groupId", getCategoryValueCascadingGroupById); + +// 관계 코드로 조회 +router.get("/code/:code", getCategoryValueCascadingByCode); + +// 그룹 생성 +router.post("/groups", createCategoryValueCascadingGroup); + +// 그룹 수정 +router.put("/groups/:groupId", updateCategoryValueCascadingGroup); + +// 그룹 삭제 +router.delete("/groups/:groupId", deleteCategoryValueCascadingGroup); + +// ============================================ +// 카테고리 값 연쇄관계 매핑 +// ============================================ + +// 매핑 일괄 저장 +router.post("/groups/:groupId/mappings", saveCategoryValueCascadingMappings); + +// ============================================ +// 연쇄 옵션 조회 (실제 드롭다운에서 사용) +// ============================================ + +// 부모 카테고리 값 목록 조회 +router.get("/parent-options/:code", getCategoryValueCascadingParentOptions); + +// 자식 카테고리 값 목록 조회 (매핑 설정 UI용) +router.get("/child-options/:code", getCategoryValueCascadingChildOptions); + +// 연쇄 옵션 조회 (부모 값 기반 자식 옵션) +router.get("/options/:code", getCategoryValueCascadingOptions); + +export default router; + diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b12d7a4a..b5266377 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,12 +10,27 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; + copiedCodeCategories: number; + copiedCodes: number; + copiedNumberingRules: number; + copiedCategoryMappings: number; + copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; warnings: string[]; } +/** + * 추가 복사 옵션 + */ +export interface AdditionalCopyOptions { + copyCodeCategory?: boolean; + copyNumberingRules?: boolean; + copyCategoryMapping?: boolean; + copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 +} + /** * 메뉴 정보 */ @@ -431,12 +446,13 @@ export class MenuCopyService { * properties 내부 참조 업데이트 */ /** - * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId 재귀 업데이트 + * properties 내부의 모든 screen_id, screenId, targetScreenId, flowId, numberingRuleId 재귀 업데이트 */ private updateReferencesInProperties( properties: any, screenIdMap: Map, - flowIdMap: Map + flowIdMap: Map, + numberingRuleIdMap?: Map ): any { if (!properties) return properties; @@ -444,7 +460,7 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap); + this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); return updated; } @@ -456,7 +472,8 @@ export class MenuCopyService { obj: any, screenIdMap: Map, flowIdMap: Map, - path: string = "" + path: string = "", + numberingRuleIdMap?: Map ): void { if (!obj || typeof obj !== "object") return; @@ -467,7 +484,8 @@ export class MenuCopyService { item, screenIdMap, flowIdMap, - `${path}[${index}]` + `${path}[${index}]`, + numberingRuleIdMap ); }); return; @@ -518,13 +536,25 @@ export class MenuCopyService { } } + // numberingRuleId 매핑 (문자열) + if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + const newRuleId = numberingRuleIdMap.get(value); + if (newRuleId) { + obj[key] = newRuleId; + logger.info( + ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` + ); + } + } + // 재귀 호출 if (typeof value === "object" && value !== null) { this.recursiveUpdateReferences( value, screenIdMap, flowIdMap, - currentPath + currentPath, + numberingRuleIdMap ); } } @@ -534,6 +564,8 @@ export class MenuCopyService { * 기존 복사본 삭제 (덮어쓰기를 위한 사전 정리) * * 같은 원본 메뉴에서 복사된 메뉴 구조가 대상 회사에 이미 존재하면 삭제 + * - 최상위 메뉴 복사: 해당 메뉴 트리 전체 삭제 + * - 하위 메뉴 복사: 해당 메뉴와 그 하위만 삭제 (부모는 유지) */ private async deleteExistingCopy( sourceMenuObjid: number, @@ -542,9 +574,9 @@ export class MenuCopyService { ): Promise { logger.info("\n🗑️ [0단계] 기존 복사본 확인 및 삭제"); - // 1. 대상 회사에 같은 이름의 최상위 메뉴가 있는지 확인 + // 1. 원본 메뉴 정보 확인 const sourceMenuResult = await client.query( - `SELECT menu_name_kor, menu_name_eng + `SELECT menu_name_kor, menu_name_eng, parent_obj_id FROM menu_info WHERE objid = $1`, [sourceMenuObjid] @@ -556,14 +588,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; + const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) - const existingMenuResult = await client.query<{ objid: number }>( - `SELECT objid + // 최상위/하위 구분 없이 모든 복사본 검색 + const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 - AND company_code = $2 - AND (parent_obj_id = 0 OR parent_obj_id IS NULL)`, + AND company_code = $2`, [sourceMenuObjid, targetCompanyCode] ); @@ -573,11 +606,14 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; + const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; + logger.info( - `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid})` + `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` ); - // 3. 기존 메뉴 트리 수집 + // 3. 기존 메뉴 트리 수집 (해당 메뉴 + 하위 메뉴 모두) const existingMenus = await this.collectMenuTree(existingMenuObjid, client); const existingMenuIds = existingMenus.map((m) => m.objid); @@ -595,16 +631,7 @@ export class MenuCopyService { // 5. 삭제 순서 (외래키 제약 고려) - // 5-1. 화면 레이아웃 삭제 - if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, - [screenIds] - ); - logger.info(` ✅ 화면 레이아웃 삭제 완료`); - } - - // 5-2. 화면-메뉴 할당 삭제 + // 5-1. 화면-메뉴 할당 먼저 삭제 (공유 화면 체크를 위해 먼저 삭제) await client.query( `DELETE FROM screen_menu_assignments WHERE menu_objid = ANY($1) AND company_code = $2`, @@ -612,23 +639,47 @@ export class MenuCopyService { ); logger.info(` ✅ 화면-메뉴 할당 삭제 완료`); - // 5-3. 화면 정의 삭제 + // 5-2. 화면 정의 삭제 (다른 메뉴에서 사용 중인 화면은 제외) if (screenIds.length > 0) { - await client.query( - `DELETE FROM screen_definitions + // 다른 메뉴에서도 사용 중인 화면 ID 조회 + const sharedScreensResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id FROM screen_menu_assignments WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - logger.info(` ✅ 화면 정의 삭제 완료`); + const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + + // 공유되지 않은 화면만 삭제 + const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + + if (screensToDelete.length > 0) { + // 레이아웃 삭제 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = ANY($1)`, + [screensToDelete] + ); + + // 화면 정의 삭제 + await client.query( + `DELETE FROM screen_definitions + WHERE screen_id = ANY($1) AND company_code = $2`, + [screensToDelete, targetCompanyCode] + ); + logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); + } + + if (sharedScreenIds.size > 0) { + logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + } } - // 5-4. 메뉴 권한 삭제 + // 5-3. 메뉴 권한 삭제 await client.query(`DELETE FROM rel_menu_auth WHERE menu_objid = ANY($1)`, [ existingMenuIds, ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ @@ -650,7 +701,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + additionalCopyOptions?: AdditionalCopyOptions ): Promise { logger.info(` 🚀 ============================================ @@ -702,6 +754,36 @@ export class MenuCopyService { client ); + // === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) === + let copiedCodeCategories = 0; + let copiedCodes = 0; + let copiedNumberingRules = 0; + let copiedCategoryMappings = 0; + let copiedTableTypeColumns = 0; + let numberingRuleIdMap = new Map(); + + const menuObjids = menus.map((m) => m.objid); + + // 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요) + const tempMenuIdMap = new Map(); + let tempObjId = await this.getNextMenuObjid(client); + for (const menu of menus) { + tempMenuIdMap.set(menu.objid, tempObjId++); + } + + if (additionalCopyOptions?.copyNumberingRules) { + logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)"); + const ruleResult = await this.copyNumberingRulesWithMap( + menuObjids, + tempMenuIdMap, + targetCompanyCode, + userId, + client + ); + copiedNumberingRules = ruleResult.copiedCount; + numberingRuleIdMap = ruleResult.ruleIdMap; + } + // === 3단계: 화면 복사 === logger.info("\n📄 [3단계] 화면 복사"); const screenIdMap = await this.copyScreens( @@ -710,7 +792,8 @@ export class MenuCopyService { flowIdMap, userId, client, - screenNameConfig + screenNameConfig, + numberingRuleIdMap ); // === 4단계: 메뉴 복사 === @@ -718,6 +801,7 @@ export class MenuCopyService { const menuIdMap = await this.copyMenus( menus, sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 + sourceCompanyCode, targetCompanyCode, screenIdMap, userId, @@ -734,6 +818,46 @@ export class MenuCopyService { client ); + // === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) === + if (additionalCopyOptions) { + // 6-1. 코드 카테고리 + 코드 복사 + if (additionalCopyOptions.copyCodeCategory) { + logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // 6-2. 카테고리 매핑 + 값 복사 + if (additionalCopyOptions.copyCategoryMapping) { + logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // 6-3. 테이블 타입관리 입력타입 설정 복사 + if (additionalCopyOptions.copyTableTypeColumns) { + logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), // 원본 화면 IDs + sourceCompanyCode, + targetCompanyCode, + client + ); + } + } + // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -743,6 +867,11 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, + copiedCodeCategories, + copiedCodes, + copiedNumberingRules, + copiedCategoryMappings, + copiedTableTypeColumns, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -755,8 +884,11 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. + - 코드 카테고리: ${copiedCodeCategories}개 + - 코드: ${copiedCodes}개 + - 채번규칙: ${copiedNumberingRules}개 + - 카테고리 매핑: ${copiedCategoryMappings}개 + - 테이블 타입 설정: ${copiedTableTypeColumns}개 ============================================ `); @@ -949,7 +1081,8 @@ export class MenuCopyService { screenNameConfig?: { removeText?: string; addPrefix?: string; - } + }, + numberingRuleIdMap?: Map ): Promise> { const screenIdMap = new Map(); @@ -984,7 +1117,7 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; // 2) 기존 복사본 찾기: source_screen_id로 검색 - const existingCopyResult = await client.query<{ + let existingCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; @@ -996,6 +1129,36 @@ export class MenuCopyService { [originalScreenId, targetCompanyCode] ); + // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { + existingCopyResult = await client.query<{ + screen_id: number; + screen_name: string; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, updated_date + FROM screen_definitions + WHERE screen_name = $1 + AND table_name = $2 + AND company_code = $3 + AND source_screen_id IS NULL + AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_name, screenDef.table_name, targetCompanyCode] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) + await client.query( + `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, + [originalScreenId, existingCopyResult.rows[0].screen_id] + ); + logger.info( + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id} ← ${originalScreenId}` + ); + } + } + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { @@ -1185,7 +1348,8 @@ export class MenuCopyService { const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, - flowIdMap + flowIdMap, + numberingRuleIdMap ); await client.query( @@ -1332,12 +1496,76 @@ export class MenuCopyService { return screenCode; } + /** + * 대상 회사에서 부모 메뉴 찾기 + * - 원본 메뉴의 parent_obj_id를 source_menu_objid로 가진 메뉴를 대상 회사에서 검색 + * - 2레벨 이하 메뉴 복사 시 기존에 복사된 부모 메뉴와 연결하기 위함 + */ + private async findParentMenuInTargetCompany( + originalParentObjId: number, + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + // 1. 대상 회사에서 source_menu_objid가 원본 부모 ID인 메뉴 찾기 + const result = await client.query<{ objid: number }>( + `SELECT objid FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [originalParentObjId, targetCompanyCode] + ); + + if (result.rows.length > 0) { + return result.rows[0].objid; + } + + // 2. source_menu_objid로 못 찾으면, 동일 원본 회사에서 복사된 메뉴 중 같은 이름으로 찾기 (fallback) + // 원본 부모 메뉴 정보 조회 + const parentMenuResult = await client.query( + `SELECT * FROM menu_info WHERE objid = $1`, + [originalParentObjId] + ); + + if (parentMenuResult.rows.length === 0) { + return null; + } + + const parentMenu = parentMenuResult.rows[0]; + + // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 + const sameNameResult = await client.query<{ objid: number }>( + `SELECT m.objid FROM menu_info m + WHERE m.menu_name_kor = $1 + AND m.company_code = $2 + AND m.source_menu_objid IS NOT NULL + AND EXISTS ( + SELECT 1 FROM menu_info orig + WHERE orig.objid = m.source_menu_objid + AND orig.company_code = $3 + ) + LIMIT 1`, + [parentMenu.menu_name_kor, targetCompanyCode, sourceCompanyCode] + ); + + if (sameNameResult.rows.length > 0) { + logger.info( + ` 📎 이름으로 부모 메뉴 찾음: "${parentMenu.menu_name_kor}" → objid: ${sameNameResult.rows[0].objid}` + ); + return sameNameResult.rows[0].objid; + } + + return null; + } + /** * 메뉴 복사 */ private async copyMenus( menus: Menu[], rootMenuObjid: number, + sourceCompanyCode: string, targetCompanyCode: string, screenIdMap: Map, userId: string, @@ -1357,27 +1585,106 @@ export class MenuCopyService { for (const menu of sortedMenus) { try { - // 새 objid 생성 - const newObjId = await this.getNextMenuObjid(client); + // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) + // 1차: source_menu_objid로 검색 + let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE source_menu_objid = $1 AND company_code = $2 + LIMIT 1`, + [menu.objid, targetCompanyCode] + ); - // parent_obj_id 재매핑 - // NULL이나 0은 최상위 메뉴를 의미하므로 0으로 통일 + // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 + if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { + existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + `SELECT objid, parent_obj_id FROM menu_info + WHERE menu_name_kor = $1 + AND company_code = $2 + AND menu_type = $3 + AND source_menu_objid IS NULL + LIMIT 1`, + [menu.menu_name_kor, targetCompanyCode, menu.menu_type] + ); + + if (existingCopyResult.rows.length > 0) { + // 기존 복사본에 source_menu_objid 업데이트 (마이그레이션) + await client.query( + `UPDATE menu_info SET source_menu_objid = $1 WHERE objid = $2`, + [menu.objid, existingCopyResult.rows[0].objid] + ); + logger.info( + ` 📝 기존 메뉴에 source_menu_objid 추가: ${existingCopyResult.rows[0].objid} ← ${menu.objid}` + ); + } + } + + // parent_obj_id 계산 (신규/재연결 모두 필요) let newParentObjId: number | null; if (!menu.parent_obj_id || menu.parent_obj_id === 0) { newParentObjId = 0; // 최상위 메뉴는 항상 0 } else { - newParentObjId = - menuIdMap.get(menu.parent_obj_id) || menu.parent_obj_id; + // 1. 현재 복사 세션에서 부모가 이미 복사되었는지 확인 + newParentObjId = menuIdMap.get(menu.parent_obj_id) || null; + + // 2. 현재 세션에서 못 찾으면, 대상 회사에서 기존에 복사된 부모 찾기 + if (!newParentObjId) { + const existingParent = await this.findParentMenuInTargetCompany( + menu.parent_obj_id, + sourceCompanyCode, + targetCompanyCode, + client + ); + + if (existingParent) { + newParentObjId = existingParent; + logger.info( + ` 🔗 기존 부모 메뉴 연결: 원본 ${menu.parent_obj_id} → 대상 ${existingParent}` + ); + } else { + // 3. 부모를 못 찾으면 최상위로 설정 (경고 로그) + newParentObjId = 0; + logger.warn( + ` ⚠️ 부모 메뉴를 찾을 수 없음: ${menu.parent_obj_id} - 최상위로 생성됨` + ); + } + } } - // source_menu_objid 저장: 원본 최상위 메뉴만 저장 (덮어쓰기 식별용) - // BigInt 타입이 문자열로 반환될 수 있으므로 문자열로 변환 후 비교 - const isRootMenu = String(menu.objid) === String(rootMenuObjid); - const sourceMenuObjid = isRootMenu ? menu.objid : null; + if (existingCopyResult.rows.length > 0) { + // === 이미 복사된 메뉴가 있는 경우: 재연결만 === + const existingMenu = existingCopyResult.rows[0]; + const existingObjId = existingMenu.objid; + const existingParentId = existingMenu.parent_obj_id; - if (sourceMenuObjid) { + // 부모가 다르면 업데이트 (고아 메뉴 재연결) + if (existingParentId !== newParentObjId) { + await client.query( + `UPDATE menu_info SET parent_obj_id = $1, writer = $2 WHERE objid = $3`, + [newParentObjId, userId, existingObjId] + ); + logger.info( + ` ♻️ 메뉴 재연결: ${menu.objid} → ${existingObjId} (${menu.menu_name_kor}), parent: ${existingParentId} → ${newParentObjId}` + ); + } else { + logger.info( + ` ⏭️ 메뉴 이미 존재 (스킵): ${menu.objid} → ${existingObjId} (${menu.menu_name_kor})` + ); + } + + menuIdMap.set(menu.objid, existingObjId); + continue; + } + + // === 신규 메뉴 복사 === + const newObjId = await this.getNextMenuObjid(client); + + // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) + const sourceMenuObjid = menu.objid; + const isRootMenu = String(menu.objid) === String(rootMenuObjid); + + if (isRootMenu) { logger.info( - ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (원본 최상위 메뉴)` + ` 📌 source_menu_objid 저장: ${sourceMenuObjid} (복사 시작 메뉴)` ); } @@ -1486,4 +1793,430 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } + /** + * 코드 카테고리 + 코드 복사 + */ + private async copyCodeCategoriesAndCodes( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCategories: number; copiedCodes: number }> { + let copiedCategories = 0; + let copiedCodes = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 코드 카테고리 조회 + const categoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const category of categoriesResult.rows) { + // 대상 회사에 같은 category_code가 이미 있는지 확인 + const existingCategory = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = $1 AND company_code = $2`, + [category.category_code, targetCompanyCode] + ); + + if (existingCategory.rows.length > 0) { + logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); + continue; + } + + // 카테고리 복사 + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, + [ + category.category_code, + category.category_name, + category.category_name_eng, + category.description, + category.sort_order, + category.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCategories++; + logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`); + + // 2. 해당 카테고리의 코드 조회 및 복사 + const codesResult = await client.query( + `SELECT * FROM code_info + WHERE code_category = $1 AND menu_objid = $2`, + [category.category_code, menuObjid] + ); + + for (const code of codesResult.rows) { + // 대상 회사에 같은 code_value가 이미 있는지 확인 + const existingCode = await client.query( + `SELECT code_value FROM code_info + WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, + [category.category_code, code.code_value, targetCompanyCode] + ); + + if (existingCode.rows.length > 0) { + logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); + continue; + } + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`, + [ + category.category_code, + code.code_value, + code.code_name, + code.code_name_eng, + code.description, + code.sort_order, + code.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ] + ); + copiedCodes++; + } + logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`); + } + } + + logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + return { copiedCategories, copiedCodes }; + } + + /** + * 채번 규칙 복사 (ID 매핑 반환 버전) + * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 + */ + private async copyNumberingRulesWithMap( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise<{ copiedCount: number; ruleIdMap: Map }> { + let copiedCount = 0; + const ruleIdMap = new Map(); + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 채번 규칙 조회 + const rulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const rule of rulesResult.rows) { + // 대상 회사에 같은 rule_id가 이미 있는지 확인 + const existingRule = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = $1 AND company_code = $2`, + [rule.rule_id, targetCompanyCode] + ); + + if (existingRule.rows.length > 0) { + logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); + // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) + ruleIdMap.set(rule.rule_id, rule.rule_id); + continue; + } + + // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) + const originalSuffix = rule.rule_id.includes('_') + ? rule.rule_id.replace(/^[^_]*_/, '') + : rule.rule_id; + const newRuleId = `${targetCompanyCode}_${originalSuffix}`; + + // 매핑 저장 (원본 rule_id → 새 rule_id) + ruleIdMap.set(rule.rule_id, newRuleId); + + // 채번 규칙 복사 + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`, + [ + newRuleId, + rule.rule_name, + rule.description, + rule.separator, + rule.reset_period, + 0, // 시퀀스는 0부터 시작 + rule.table_name, + rule.column_name, + targetCompanyCode, + userId, + newMenuObjid, + rule.scope_type, + null, // 마지막 생성일은 null로 초기화 + ] + ); + copiedCount++; + logger.info(` ✅ 채번규칙 복사: ${rule.rule_id} → ${newRuleId}`); + + // 채번 규칙 파트 복사 + const partsResult = await client.query( + `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, + [rule.rule_id] + ); + + for (const part of partsResult.rows) { + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, + [ + newRuleId, + part.part_order, + part.part_type, + part.generation_method, + part.auto_config, + part.manual_config, + targetCompanyCode, + ] + ); + } + logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`); + } + } + + logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + return { copiedCount, ruleIdMap }; + } + + /** + * 카테고리 매핑 + 값 복사 + */ + private async copyCategoryMappingsAndValues( + menuObjids: number[], + menuIdMap: Map, + targetCompanyCode: string, + userId: string, + client: PoolClient + ): Promise { + let copiedCount = 0; + + for (const menuObjid of menuObjids) { + const newMenuObjid = menuIdMap.get(menuObjid); + if (!newMenuObjid) continue; + + // 1. 카테고리 컬럼 매핑 조회 + const mappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, + [menuObjid] + ); + + for (const mapping of mappingsResult.rows) { + // 대상 회사에 같은 매핑이 이미 있는지 확인 + const existingMapping = await client.query( + `SELECT mapping_id FROM category_column_mapping + WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`, + [mapping.table_name, mapping.logical_column_name, targetCompanyCode] + ); + + let newMappingId: number; + + if (existingMapping.rows.length > 0) { + logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`); + newMappingId = existingMapping.rows[0].mapping_id; + } else { + // 매핑 복사 + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) + RETURNING mapping_id`, + [ + mapping.table_name, + mapping.logical_column_name, + mapping.physical_column_name, + newMenuObjid, + targetCompanyCode, + mapping.description, + userId, + ] + ); + newMappingId = insertResult.rows[0].mapping_id; + copiedCount++; + logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`); + } + + // 2. 카테고리 값 조회 및 복사 (menu_objid 기준) + const valuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3 + ORDER BY parent_value_id NULLS FIRST, value_order`, + [mapping.table_name, mapping.logical_column_name, menuObjid] + ); + + // 값 ID 매핑 (부모-자식 관계 유지를 위해) + const valueIdMap = new Map(); + + for (const value of valuesResult.rows) { + // 대상 회사에 같은 값이 이미 있는지 확인 + const existingValue = await client.query( + `SELECT value_id FROM table_column_category_values + WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, + [value.table_name, value.column_name, value.value_code, targetCompanyCode] + ); + + if (existingValue.rows.length > 0) { + valueIdMap.set(value.value_id, existingValue.rows[0].value_id); + continue; + } + + // 부모 ID 재매핑 + const newParentId = value.parent_value_id + ? valueIdMap.get(value.parent_value_id) || null + : null; + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, company_code, created_at, created_by, menu_objid + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15) + RETURNING value_id`, + [ + value.table_name, + value.column_name, + value.value_code, + value.value_label, + value.value_order, + newParentId, + value.depth, + value.description, + value.color, + value.icon, + value.is_active, + value.is_default, + targetCompanyCode, + userId, + newMenuObjid, + ] + ); + + valueIdMap.set(value.value_id, insertResult.rows[0].value_id); + } + + if (valuesResult.rows.length > 0) { + logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`); + } + } + } + + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } + + /** + * 테이블 타입관리 입력타입 설정 복사 + * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 + */ + private async copyTableTypeColumns( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + client: PoolClient + ): Promise { + if (screenIds.length === 0) { + return 0; + } + + logger.info(`📋 테이블 타입 설정 복사 시작`); + logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + + // 1. 복사된 화면에서 사용하는 테이블 목록 조회 + const tablesResult = await client.query<{ table_name: string }>( + `SELECT DISTINCT table_name FROM screen_definitions + WHERE screen_id = ANY($1) AND table_name IS NOT NULL AND table_name != ''`, + [screenIds] + ); + + if (tablesResult.rows.length === 0) { + logger.info(" ⚠️ 복사된 화면에 테이블이 없음"); + return 0; + } + + const tableNames = tablesResult.rows.map((r) => r.table_name); + logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + + let copiedCount = 0; + + for (const tableName of tableNames) { + // 2. 원본 회사의 테이블 타입 설정 조회 + const sourceSettings = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = $1 AND company_code = $2`, + [tableName, sourceCompanyCode] + ); + + if (sourceSettings.rows.length === 0) { + logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`); + continue; + } + + for (const setting of sourceSettings.rows) { + // 3. 대상 회사에 같은 설정이 이미 있는지 확인 + const existing = await client.query( + `SELECT id FROM table_type_columns + WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, + [setting.table_name, setting.column_name, targetCompanyCode] + ); + + if (existing.rows.length > 0) { + // 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지) + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)` + ); + continue; + } + + // 새로 삽입 + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, + [ + setting.table_name, + setting.column_name, + setting.input_type, + setting.detail_settings, + setting.is_nullable, + setting.display_order, + targetCompanyCode, + ] + ); + logger.info( + ` ↳ ${setting.table_name}.${setting.column_name}: 신규 추가` + ); + copiedCount++; + } + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); + return copiedCount; + } + } diff --git a/frontend/app/(main)/admin/cascading-management/page.tsx b/frontend/app/(main)/admin/cascading-management/page.tsx index 70382dd9..5b5f6b37 100644 --- a/frontend/app/(main)/admin/cascading-management/page.tsx +++ b/frontend/app/(main)/admin/cascading-management/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Link2, Layers, Filter, FormInput, Ban } from "lucide-react"; +import { Link2, Layers, Filter, FormInput, Ban, Tags } from "lucide-react"; // 탭별 컴포넌트 import CascadingRelationsTab from "./tabs/CascadingRelationsTab"; @@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab"; import HierarchyTab from "./tabs/HierarchyTab"; import ConditionTab from "./tabs/ConditionTab"; import MutualExclusionTab from "./tabs/MutualExclusionTab"; +import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab"; export default function CascadingManagementPage() { const searchParams = useSearchParams(); @@ -20,7 +21,7 @@ export default function CascadingManagementPage() { // URL 쿼리 파라미터에서 탭 설정 useEffect(() => { const tab = searchParams.get("tab"); - if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion"].includes(tab)) { + if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value"].includes(tab)) { setActiveTab(tab); } }, [searchParams]); @@ -46,7 +47,7 @@ export default function CascadingManagementPage() { {/* 탭 네비게이션 */} - + 2단계 연쇄관계 @@ -72,6 +73,11 @@ export default function CascadingManagementPage() { 상호 배제 배제 + + + 카테고리값 + 카테고리 + {/* 탭 컨텐츠 */} @@ -95,6 +101,10 @@ export default function CascadingManagementPage() { + + + + diff --git a/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx new file mode 100644 index 00000000..ccb439e1 --- /dev/null +++ b/frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx @@ -0,0 +1,1009 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + categoryValueCascadingApi, + CategoryValueCascadingGroup, + CategoryValueCascadingGroupInput, + CategoryValueCascadingMappingInput, +} from "@/lib/api/categoryValueCascading"; +import { tableManagementApi } from "@/lib/api/tableManagement"; + +interface TableInfo { + tableName: string; + displayName: string; + description?: string; + columnCount?: number; +} + +interface ColumnInfo { + columnName: string; + displayName: string; + dataType?: string; + inputType?: string; + input_type?: string; +} + +interface CategoryValue { + value: string; + label: string; +} + +export default function CategoryValueCascadingTab() { + // 상태 + const [groups, setGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // 테이블/컬럼 목록 + const [tables, setTables] = useState([]); + const [parentColumns, setParentColumns] = useState([]); + const [childColumns, setChildColumns] = useState([]); + + // Combobox 상태 + const [parentTableOpen, setParentTableOpen] = useState(false); + const [childTableOpen, setChildTableOpen] = useState(false); + + // 모달 상태 + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isMappingModalOpen, setIsMappingModalOpen] = useState(false); + const [selectedGroup, setSelectedGroup] = useState(null); + + // 폼 상태 + const [formData, setFormData] = useState({ + relationCode: "", + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + + // 매핑 상태 + const [parentValues, setParentValues] = useState([]); + const [childValues, setChildValues] = useState([]); + const [mappings, setMappings] = useState>>({}); + const [savingMappings, setSavingMappings] = useState(false); + + // 직접 입력 매핑 상태 (각 부모값에 대한 하위 옵션 목록) + const [childOptionsMap, setChildOptionsMap] = useState>({}); + const [newOptionInputs, setNewOptionInputs] = useState>({}); + + // 검색 + const [searchText, setSearchText] = useState(""); + + // 그룹 목록 로드 + const loadGroups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await categoryValueCascadingApi.getGroups("Y"); + if (response.success && response.data) { + setGroups(response.data); + } else { + setError(response.error || "그룹 목록 로드 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setLoading(false); + } + }, []); + + // 테이블 목록 로드 + const loadTables = useCallback(async () => { + try { + console.log("📦 테이블 목록 로드 시작"); + const response = await tableManagementApi.getTableList(); + console.log("📦 테이블 목록 응답:", response); + if (response.success && response.data) { + console.log("✅ 테이블 목록 로드 성공:", response.data.length, "개"); + setTables(response.data); + } else { + console.error("❌ 테이블 목록 로드 실패:", response); + } + } catch (err: any) { + console.error("테이블 목록 로드 실패:", err); + } + }, []); + + // 컬럼 목록 로드 + const loadColumns = useCallback(async (tableName: string, target: "parent" | "child") => { + if (!tableName) { + if (target === "parent") setParentColumns([]); + else setChildColumns([]); + return; + } + + try { + const response = await tableManagementApi.getColumnList(tableName); + if (response.success && response.data) { + // API 응답 형식: { columns: [...], total, page, ... } + const columns = response.data.columns || response.data; + const columnsArray = Array.isArray(columns) ? columns : []; + + // category 타입 컬럼만 필터링 + const categoryColumns = columnsArray.filter( + (col: any) => col.input_type === "category" || col.inputType === "category" + ); + + // 인터페이스에 맞게 변환 + const mappedColumns: ColumnInfo[] = categoryColumns.map((col: any) => ({ + columnName: col.columnName || col.column_name, + displayName: col.displayName || col.column_label || col.columnName || col.column_name, + dataType: col.dataType || col.data_type, + inputType: col.inputType || col.input_type, + })); + + if (target === "parent") setParentColumns(mappedColumns); + else setChildColumns(mappedColumns); + } + } catch (err: any) { + console.error("컬럼 목록 로드 실패:", err); + } + }, []); + + // 초기 로드 + useEffect(() => { + loadGroups(); + loadTables(); + }, [loadGroups, loadTables]); + + // 필터링된 그룹 + const filteredGroups = groups.filter((group) => { + if (!searchText) return true; + const lowerSearch = searchText.toLowerCase(); + return ( + group.relation_code.toLowerCase().includes(lowerSearch) || + group.relation_name.toLowerCase().includes(lowerSearch) || + group.parent_table_name.toLowerCase().includes(lowerSearch) || + group.child_table_name.toLowerCase().includes(lowerSearch) + ); + }); + + // 폼 초기화 + const resetForm = () => { + // 자동 관계코드 생성 + const autoCode = `CVC_${Date.now()}`; + setFormData({ + relationCode: autoCode, + relationName: "", + description: "", + parentTableName: "", + parentColumnName: "", + childTableName: "", + childColumnName: "", + clearOnParentChange: true, + showGroupLabel: true, + emptyParentMessage: "상위 항목을 먼저 선택하세요", + noOptionsMessage: "선택 가능한 항목이 없습니다", + }); + setParentColumns([]); + setChildColumns([]); + }; + + // 생성 모달 열기 + const openCreateModal = async () => { + resetForm(); + // 테이블 목록이 없으면 다시 로드 + if (tables.length === 0) { + console.log("📦 테이블 목록이 비어있어서 다시 로드"); + await loadTables(); + } + setIsCreateModalOpen(true); + }; + + // 수정 모달 열기 + const openEditModal = (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setFormData({ + relationCode: group.relation_code, + relationName: group.relation_name, + description: group.description || "", + parentTableName: group.parent_table_name, + parentColumnName: group.parent_column_name, + childTableName: group.child_table_name, + childColumnName: group.child_column_name, + clearOnParentChange: group.clear_on_parent_change === "Y", + showGroupLabel: group.show_group_label === "Y", + emptyParentMessage: group.empty_parent_message || "", + noOptionsMessage: group.no_options_message || "", + }); + loadColumns(group.parent_table_name, "parent"); + loadColumns(group.child_table_name, "child"); + setIsEditModalOpen(true); + }; + + // 매핑 모달 열기 + const openMappingModal = async (group: CategoryValueCascadingGroup) => { + setSelectedGroup(group); + setMappings({}); + setParentValues([]); + setChildValues([]); + setChildOptionsMap({}); + setNewOptionInputs({}); + + try { + // 부모 카테고리 값과 기존 매핑 로드 + const [parentResponse, groupDetailResponse] = await Promise.all([ + categoryValueCascadingApi.getParentOptions(group.relation_code), + categoryValueCascadingApi.getGroupById(group.group_id), + ]); + + if (parentResponse.success && parentResponse.data) { + setParentValues(parentResponse.data); + + // 부모 값별 입력창 초기화 + const inputs: Record = {}; + for (const pv of parentResponse.data) { + inputs[pv.value] = ""; + } + setNewOptionInputs(inputs); + } + + // 기존 매핑을 직접 입력 형태로 변환 + if (groupDetailResponse.success && groupDetailResponse.data?.mappings) { + const optionsMap: Record = {}; + + for (const mapping of groupDetailResponse.data.mappings) { + const parentCode = mapping.parent_value_code; + if (!optionsMap[parentCode]) { + optionsMap[parentCode] = []; + } + // 중복 체크 + if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) { + optionsMap[parentCode].push({ + code: mapping.child_value_code, + label: mapping.child_value_label || mapping.child_value_code, + }); + } + } + setChildOptionsMap(optionsMap); + } + + setIsMappingModalOpen(true); + } catch (err: any) { + console.error("매핑 데이터 로드 실패:", err); + setError("매핑 데이터 로드 실패"); + } + }; + + // 고유 코드 생성 함수 + const generateUniqueCode = () => { + const timestamp = Date.now().toString(36).toUpperCase(); + const random = Math.random().toString(36).substring(2, 6).toUpperCase(); + return `TARGET_${timestamp}${random}`; + }; + + // 하위 옵션 추가 + const addChildOption = (parentValue: string) => { + const inputValue = newOptionInputs[parentValue]?.trim(); + if (!inputValue) return; + + // 자동 고유 코드 생성 + const code = generateUniqueCode(); + + setChildOptionsMap((prev) => { + const currentOptions = prev[parentValue] || []; + // 중복 체크 (라벨만 체크 - 코드는 항상 고유) + if (currentOptions.some((opt) => opt.label === inputValue)) { + return prev; + } + return { + ...prev, + [parentValue]: [...currentOptions, { code, label: inputValue }], + }; + }); + + // 입력창 초기화 + setNewOptionInputs((prev) => ({ ...prev, [parentValue]: "" })); + }; + + // 하위 옵션 삭제 + const removeChildOption = (parentValue: string, optionCode: string) => { + setChildOptionsMap((prev) => ({ + ...prev, + [parentValue]: (prev[parentValue] || []).filter((opt) => opt.code !== optionCode), + })); + }; + + // 그룹 생성 + const handleCreate = async () => { + try { + const response = await categoryValueCascadingApi.createGroup(formData); + if (response.success) { + setIsCreateModalOpen(false); + loadGroups(); + } else { + setError(response.error || "생성 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 수정 + const handleUpdate = async () => { + if (!selectedGroup) return; + + try { + const response = await categoryValueCascadingApi.updateGroup(selectedGroup.group_id, formData); + if (response.success) { + setIsEditModalOpen(false); + loadGroups(); + } else { + setError(response.error || "수정 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 그룹 삭제 + const handleDelete = async (group: CategoryValueCascadingGroup) => { + if (!confirm(`"${group.relation_name}" 연쇄관계를 삭제하시겠습니까?`)) return; + + try { + const response = await categoryValueCascadingApi.deleteGroup(group.group_id); + if (response.success) { + loadGroups(); + } else { + setError(response.error || "삭제 실패"); + } + } catch (err: any) { + setError(err.message); + } + }; + + // 매핑 토글 + const toggleMapping = (parentCode: string, childCode: string) => { + setMappings((prev) => { + const newMappings = { ...prev }; + if (!newMappings[parentCode]) { + newMappings[parentCode] = new Set(); + } + + const newSet = new Set(newMappings[parentCode]); + if (newSet.has(childCode)) { + newSet.delete(childCode); + } else { + newSet.add(childCode); + } + newMappings[parentCode] = newSet; + + return newMappings; + }); + }; + + // 매핑 저장 + const handleSaveMappings = async () => { + if (!selectedGroup) return; + + setSavingMappings(true); + try { + // 직접 입력된 옵션으로 매핑 데이터 생성 + const mappingInputs: CategoryValueCascadingMappingInput[] = []; + let displayOrder = 0; + + for (const parentCode of Object.keys(childOptionsMap)) { + const parentValue = parentValues.find((p) => p.value === parentCode); + const childOptions = childOptionsMap[parentCode] || []; + + for (const childOption of childOptions) { + mappingInputs.push({ + parentValueCode: parentCode, + parentValueLabel: parentValue?.label, + childValueCode: childOption.code, + childValueLabel: childOption.label, + displayOrder: displayOrder++, + }); + } + } + + const response = await categoryValueCascadingApi.saveMappings( + selectedGroup.group_id, + mappingInputs + ); + + if (response.success) { + setIsMappingModalOpen(false); + } else { + setError(response.error || "매핑 저장 실패"); + } + } catch (err: any) { + setError(err.message); + } finally { + setSavingMappings(false); + } + }; + + // 활성/비활성 토글 + const toggleActive = async (group: CategoryValueCascadingGroup) => { + try { + const newActive = group.is_active !== "Y"; + const response = await categoryValueCascadingApi.updateGroup(group.group_id, { + isActive: newActive, + }); + if (response.success) { + loadGroups(); + } + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+ {/* 설명 */} +
+

카테고리 값 연쇄관계

+

+ 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. 예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시 +

+
+ + {/* 에러 메시지 */} + {error && ( +
+
+

오류

+ +
+

{error}

+
+ )} + + {/* 검색 및 액션 */} +
+
+
+ + setSearchText(e.target.value)} + className="h-10 pl-10 text-sm" + /> +
+
+ +
+
+ 총 {filteredGroups.length} 건 +
+ + +
+
+ + {/* 테이블 */} +
+ + + + 관계코드 + 관계명 + 부모 (테이블.컬럼) + 자식 (테이블.컬럼) + 사용 + 관리 + + + + {loading ? ( + Array.from({ length: 5 }).map((_, idx) => ( + + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + )) + ) : filteredGroups.length === 0 ? ( + + + 등록된 카테고리 값 연쇄관계가 없습니다. + + + ) : ( + filteredGroups.map((group) => ( + + {group.relation_code} + {group.relation_name} + + {group.parent_table_name}. + {group.parent_column_name} + + + {group.child_table_name}. + {group.child_column_name} + + + toggleActive(group)} + aria-label="활성화 토글" + /> + + +
+ + + +
+
+
+ )) + )} + +
+
+ + {/* 생성 모달 */} + + + + 카테고리 값 연쇄관계 등록 + + 카테고리 값들 간의 부모-자식 연쇄관계를 정의합니다. + + + +
+ {/* 기본 정보 */} +
+ + setFormData({ ...formData, relationName: e.target.value })} + placeholder="예: 검사유형-적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ +
+ + setFormData({ ...formData, description: e.target.value })} + placeholder="연쇄관계 설명" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모 설정 */} +
+

부모 카테고리 설정

+
+
+ + + + + + + + + + + {tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."} + + + {tables.map((table) => ( + { + setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName }); + loadColumns(table.tableName, "parent"); + setParentTableOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.displayName || table.tableName} + {table.displayName && table.displayName !== table.tableName && ( + {table.tableName} + )} +
+
+ ))} +
+
+
+
+
+
+
+ + +
+
+
+ + {/* 자식 옵션 라벨 설정 */} +
+

하위 옵션 설정

+

+ 부모 카테고리 값별로 표시할 하위 옵션들의 그룹명을 입력합니다. +
+ 실제 하위 옵션은 등록 후 "값 매핑" 버튼에서 직접 입력합니다. +

+
+ + setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })} + placeholder="예: 적용대상" + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 수정 모달 */} + + + + 카테고리 값 연쇄관계 수정 + + 연쇄관계 설정을 수정합니다. + + + +
+ {/* 기본 정보 */} +
+
+ + +
+
+ + setFormData({ ...formData, relationName: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+ + setFormData({ ...formData, description: e.target.value })} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +
+ + {/* 부모/자식 설정 - 수정 불가 표시 */} +
+

부모/자식 설정 (수정 불가)

+
+
+ 부모: + {formData.parentTableName}.{formData.parentColumnName} +
+
+ 자식: + {formData.childTableName}.{formData.childColumnName} +
+
+
+ + {/* 옵션 */} +
+ + +
+
+ + + + + +
+
+ + {/* 값 매핑 모달 */} + + + + + 하위 옵션 설정 - {selectedGroup?.relation_name} + + + 각 부모 카테고리 값에 대해 하위 옵션을 직접 입력합니다. + + + +
+ {parentValues.length === 0 ? ( +
+ 부모 카테고리 값이 등록되지 않았습니다. +
+ 먼저 카테고리 관리에서 "{selectedGroup?.parent_column_name}" 컬럼의 값을 등록하세요. +
+ ) : ( +
+ {parentValues.map((parent) => ( +
+
+

{parent.label}

+ + {(childOptionsMap[parent.value] || []).length}개 옵션 + +
+ + {/* 하위 옵션 입력 */} +
+ + setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value })) + } + onKeyDown={(e) => { + // 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지) + if (e.nativeEvent.isComposing) return; + if (e.key === "Enter") { + e.preventDefault(); + addChildOption(parent.value); + } + }} + placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼" + className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" + /> + +
+ + {/* 등록된 하위 옵션 목록 */} +
+ {(childOptionsMap[parent.value] || []).map((option) => ( +
+ {option.label} + +
+ ))} + {(childOptionsMap[parent.value] || []).length === 0 && ( + 등록된 하위 옵션이 없습니다 + )} +
+
+ ))} +
+ )} +
+ + + + + +
+
+
+ ); +} + diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index 58b2c896..88d29de6 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -56,6 +56,12 @@ export function MenuCopyDialog({ const [removeText, setRemoveText] = useState(""); const [addPrefix, setAddPrefix] = useState(""); + // 카테고리/코드 복사 옵션 + const [copyCodeCategory, setCopyCodeCategory] = useState(false); + const [copyNumberingRules, setCopyNumberingRules] = useState(false); + const [copyCategoryMapping, setCopyCategoryMapping] = useState(false); + const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false); + // 회사 목록 로드 useEffect(() => { if (open) { @@ -66,6 +72,10 @@ export function MenuCopyDialog({ setUseBulkRename(false); setRemoveText(""); setAddPrefix(""); + setCopyCodeCategory(false); + setCopyNumberingRules(false); + setCopyCategoryMapping(false); + setCopyTableTypeColumns(false); } }, [open]); @@ -112,10 +122,19 @@ export function MenuCopyDialog({ } : undefined; + // 추가 복사 옵션 + const additionalCopyOptions = { + copyCodeCategory, + copyNumberingRules, + copyCategoryMapping, + copyTableTypeColumns, + }; + const response = await menuApi.copyMenu( menuObjid, targetCompanyCode, - screenNameConfig + screenNameConfig, + additionalCopyOptions ); if (response.success && response.data) { @@ -264,19 +283,82 @@ export function MenuCopyDialog({ )} + {/* 추가 복사 옵션 */} + {!result && ( +
+

추가 복사 옵션 (선택사항):

+
+
+ setCopyCodeCategory(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyNumberingRules(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyCategoryMapping(checked as boolean)} + disabled={copying} + /> + +
+
+ setCopyTableTypeColumns(checked as boolean)} + disabled={copying} + /> + +
+
+
+ )} + {/* 복사 항목 안내 */} {!result && (
-

복사되는 항목:

+

기본 복사 항목:

  • 메뉴 구조 (하위 메뉴 포함)
  • 화면 + 레이아웃 (모달, 조건부 컨테이너)
  • 플로우 제어 (스텝, 연결)
  • -
  • 코드 카테고리 + 코드
  • -
  • 카테고리 설정 + 채번 규칙
-

- ⚠️ 실제 데이터는 복사되지 않습니다. +

+ * 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.

)} @@ -294,10 +376,40 @@ export function MenuCopyDialog({ 화면:{" "} {result.copiedScreens}개 -
+
플로우:{" "} {result.copiedFlows}개
+ {(result.copiedCodeCategories ?? 0) > 0 && ( +
+ 코드 카테고리:{" "} + {result.copiedCodeCategories}개 +
+ )} + {(result.copiedCodes ?? 0) > 0 && ( +
+ 코드:{" "} + {result.copiedCodes}개 +
+ )} + {(result.copiedNumberingRules ?? 0) > 0 && ( +
+ 채번규칙:{" "} + {result.copiedNumberingRules}개 +
+ )} + {(result.copiedCategoryMappings ?? 0) > 0 && ( +
+ 카테고리 매핑:{" "} + {result.copiedCategoryMappings}개 +
+ )} + {(result.copiedTableTypeColumns ?? 0) > 0 && ( +
+ 테이블 타입 설정:{" "} + {result.copiedTableTypeColumns}개 +
+ )}
)} diff --git a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx index c03dac58..64acd942 100644 --- a/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx +++ b/frontend/components/screen/table-options/ColumnVisibilityPanel.tsx @@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { GripVertical, Eye, EyeOff } from "lucide-react"; +import { GripVertical, Eye, EyeOff, Lock } from "lucide-react"; import { ColumnVisibility } from "@/types/table-options"; interface Props { @@ -30,6 +30,7 @@ export const ColumnVisibilityPanel: React.FC = ({ const [localColumns, setLocalColumns] = useState([]); const [draggedIndex, setDraggedIndex] = useState(null); + const [frozenColumnCount, setFrozenColumnCount] = useState(0); // 테이블 정보 로드 useEffect(() => { @@ -42,6 +43,8 @@ export const ColumnVisibilityPanel: React.FC = ({ order: 0, })) ); + // 현재 틀고정 컬럼 수 로드 + setFrozenColumnCount(table.frozenColumnCount ?? 0); } }, [table]); @@ -94,6 +97,11 @@ export const ColumnVisibilityPanel: React.FC = ({ table.onColumnOrderChange(newOrder); } + // 틀고정 컬럼 수 변경 콜백 호출 + if (table?.onFrozenColumnCountChange) { + table.onFrozenColumnCountChange(frozenColumnCount); + } + onClose(); }; @@ -107,9 +115,18 @@ export const ColumnVisibilityPanel: React.FC = ({ order: 0, })) ); + setFrozenColumnCount(0); } }; + // 틀고정 컬럼 수 변경 핸들러 + const handleFrozenColumnCountChange = (value: string) => { + const count = parseInt(value) || 0; + // 최대값은 표시 가능한 컬럼 수 + const maxCount = localColumns.filter((col) => col.visible).length; + setFrozenColumnCount(Math.min(Math.max(0, count), maxCount)); + }; + const visibleCount = localColumns.filter((col) => col.visible).length; return ( @@ -126,11 +143,34 @@ export const ColumnVisibilityPanel: React.FC = ({
- {/* 상태 표시 */} -
-
- {visibleCount}/{localColumns.length}개 컬럼 표시 중 + {/* 상태 표시 및 틀고정 설정 */} +
+
+
+ {visibleCount}/{localColumns.length}개 컬럼 표시 중 +
+ + {/* 틀고정 설정 */} +
+ + + handleFrozenColumnCountChange(e.target.value)} + className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm" + min={0} + max={visibleCount} + placeholder="0" + /> + + 개 컬럼 + +
+ - )} -
- {isAutoDetected ? ( -

- 자동 감지됨: {parentComp.label || parentComp.columnName} -

- ) : parentComp ? ( -

- 감지된 부모 필드: {parentComp.columnName} ({parentComp.label || "라벨 없음"}) -

- ) : ( -

- 같은 관계의 부모 역할 필드가 없습니다. 수동으로 입력하세요. -

+ {config.cascadingRelationCode && config.cascadingRole === "child" && (() => { + // 선택된 관계에서 부모 값 컬럼 가져오기 + const expectedParentColumn = selectedRelation?.parent_value_column; + + // 부모 역할에 맞는 컴포넌트만 필터링 + const parentFieldCandidates = allComponents.filter((comp) => { + // 현재 컴포넌트 제외 + if (currentComponent && comp.id === currentComponent.id) return false; + // 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만 + if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false; + // columnName이 있어야 함 + return !!comp.columnName; + }); + + return ( +
+ + {expectedParentColumn && ( +

+ 관계에서 지정된 부모 컬럼: {expectedParentColumn} +

+ )} + +

+ 상위 값을 제공할 필드를 선택하세요. +

+
+ ); + })()} {/* 선택된 관계 정보 표시 */} {selectedRelation && config.cascadingRole && ( @@ -374,6 +434,152 @@ export const SelectBasicConfigPanel: React.FC = ({
)}
+ + {/* 🆕 카테고리 값 연쇄관계 설정 */} +
+
+
+ + +
+ +
+

+ 부모 카테고리 값 선택에 따라 자식 카테고리 옵션이 변경됩니다. +
예: 검사유형 선택 시 해당 유형에 맞는 적용대상만 표시 +

+ + {categoryRelationEnabled && ( +
+ {/* 관계 선택 */} +
+ + +
+ + {/* 역할 선택 */} + {(config as any).categoryRelationCode && ( +
+ +
+ + +
+

+ {config.cascadingRole === "parent" + ? "이 필드가 상위 카테고리 선택 역할을 합니다. (예: 검사유형)" + : config.cascadingRole === "child" + ? "이 필드는 상위 카테고리 값에 따라 옵션이 변경됩니다. (예: 적용대상)" + : "이 필드의 역할을 선택하세요."} +

+
+ )} + + {/* 부모 필드 설정 (자식 역할일 때만) */} + {(config as any).categoryRelationCode && config.cascadingRole === "child" && (() => { + // 선택된 관계 정보 가져오기 + const selectedRelation = categoryRelationList.find( + (r) => r.relation_code === (config as any).categoryRelationCode + ); + const expectedParentColumn = selectedRelation?.parent_column_name; + + // 부모 역할에 맞는 컴포넌트만 필터링 + const parentFieldCandidates = allComponents.filter((comp) => { + // 현재 컴포넌트 제외 + if (currentComponent && comp.id === currentComponent.id) return false; + // 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만 + if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false; + // columnName이 있어야 함 + return !!comp.columnName; + }); + + return ( +
+ + {expectedParentColumn && ( +

+ 관계에서 지정된 부모 컬럼: {expectedParentColumn} +

+ )} + +

+ 상위 카테고리 값을 제공할 필드를 선택하세요. +

+
+ ); + })()} + + {/* 관계 관리 페이지 링크 */} +
+ + + +
+
+ )} +
); }; diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 811e3ca3..171c65bb 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -687,7 +687,11 @@ export const TableListComponent: React.FC = ({ const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); - const [frozenColumns, setFrozenColumns] = useState([]); + // 체크박스 컬럼은 항상 기본 틀고정 + const [frozenColumns, setFrozenColumns] = useState( + (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [] + ); + const [frozenColumnCount, setFrozenColumnCount] = useState(0); // 🆕 Search Panel (통합 검색) 관련 상태 const [globalSearchTerm, setGlobalSearchTerm] = useState(""); @@ -1022,6 +1026,19 @@ export const TableListComponent: React.FC = ({ onColumnVisibilityChange: setColumnVisibility, getColumnUniqueValues, // 고유 값 조회 함수 등록 onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정 + // 틀고정 컬럼 관련 + frozenColumnCount, // 현재 틀고정 컬럼 수 + onFrozenColumnCountChange: (count: number) => { + setFrozenColumnCount(count); + // 체크박스 컬럼은 항상 틀고정에 포함 + const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; + // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 + const visibleCols = columnsToRegister + .filter((col) => col.visible !== false) + .map((col) => col.columnName || col.field); + const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; + setFrozenColumns(newFrozenColumns); + }, // 탭 관련 정보 (탭 내부의 테이블인 경우) parentTabId, parentTabsComponentId, @@ -1033,6 +1050,7 @@ export const TableListComponent: React.FC = ({ return () => { unregisterTable(tableId); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ tableId, tableConfig.selectedTable, @@ -1044,7 +1062,8 @@ export const TableListComponent: React.FC = ({ data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용) totalItems, // 전체 항목 수가 변경되면 재등록 registerTable, - unregisterTable, + // unregisterTable은 의존성에서 제외 - 무한 루프 방지 + // unregisterTable 함수는 의존성이 없어 안정적임 ]); // 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기 @@ -2877,6 +2896,7 @@ export const TableListComponent: React.FC = ({ sortDirection, groupByColumns, frozenColumns, + frozenColumnCount, // 틀고정 컬럼 수 저장 showGridLines, headerFilters: Object.fromEntries( Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set)]), @@ -2898,6 +2918,7 @@ export const TableListComponent: React.FC = ({ sortDirection, groupByColumns, frozenColumns, + frozenColumnCount, showGridLines, headerFilters, localPageSize, @@ -2918,7 +2939,15 @@ export const TableListComponent: React.FC = ({ if (state.sortColumn !== undefined) setSortColumn(state.sortColumn); if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); - if (state.frozenColumns) setFrozenColumns(state.frozenColumns); + if (state.frozenColumns) { + // 체크박스 컬럼이 항상 포함되도록 보장 + const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; + const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn) + ? [checkboxColumn, ...state.frozenColumns] + : state.frozenColumns; + setFrozenColumns(restoredFrozenColumns); + } + if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines); if (state.headerFilters) { const filters: Record> = {}; @@ -5588,7 +5617,8 @@ export const TableListComponent: React.FC = ({ if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; + // 체크박스 컬럼은 48px 고정 + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); leftPosition += frozenColWidth; } } @@ -5607,7 +5637,7 @@ export const TableListComponent: React.FC = ({ column.sortable !== false && column.columnName !== "__checkbox__" && "hover:bg-muted/70 cursor-pointer transition-colors", - isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", + isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 isColumnDragEnabled && column.columnName !== "__checkbox__" && @@ -5899,7 +5929,8 @@ export const TableListComponent: React.FC = ({ if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; + // 체크박스 컬럼은 48px 고정 + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); leftPosition += frozenColWidth; } } @@ -5912,7 +5943,7 @@ export const TableListComponent: React.FC = ({ column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", - isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", + isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", )} style={{ textAlign: @@ -5927,7 +5958,10 @@ export const TableListComponent: React.FC = ({ : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { left: `${leftPosition}px` }), + ...(isFrozen && { + left: `${leftPosition}px`, + backgroundColor: "hsl(var(--background))", + }), }} > {column.columnName === "__checkbox__" @@ -6059,7 +6093,8 @@ export const TableListComponent: React.FC = ({ if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; + // 체크박스 컬럼은 48px 고정 + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); leftPosition += frozenColWidth; } } @@ -6072,7 +6107,7 @@ export const TableListComponent: React.FC = ({ className={cn( "text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", - isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", + isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", // 🆕 포커스된 셀 스타일 isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", // 🆕 편집 중인 셀 스타일 @@ -6099,7 +6134,10 @@ export const TableListComponent: React.FC = ({ column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { left: `${leftPosition}px` }), + ...(isFrozen && { + left: `${leftPosition}px`, + backgroundColor: "hsl(var(--background))", + }), }} onClick={(e) => handleCellClick(index, colIndex, e)} onDoubleClick={() => @@ -6220,7 +6258,8 @@ export const TableListComponent: React.FC = ({ if (isFrozen && frozenIndex > 0) { for (let i = 0; i < frozenIndex; i++) { const frozenCol = frozenColumns[i]; - const frozenColWidth = columnWidths[frozenCol] || 150; + // 체크박스 컬럼은 48px 고정 + const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150); leftPosition += frozenColWidth; } } @@ -6235,7 +6274,7 @@ export const TableListComponent: React.FC = ({ className={cn( "text-foreground text-xs font-semibold sm:text-sm", column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4", - isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]", + isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", )} style={{ textAlign: isNumeric ? "right" : column.align || "left", @@ -6245,7 +6284,10 @@ export const TableListComponent: React.FC = ({ : columnWidth ? `${columnWidth}px` : undefined, - ...(isFrozen && { left: `${leftPosition}px` }), + ...(isFrozen && { + left: `${leftPosition}px`, + backgroundColor: "hsl(var(--muted) / 0.8)", + }), }} > {summary ? ( diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx index e486338a..2bf77042 100644 --- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx +++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx @@ -138,33 +138,84 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // currentTable은 tableList(필터링된 목록)에서 가져와야 함 const currentTable = useMemo(() => { + console.log("🔍 [TableSearchWidget] currentTable 계산:", { + selectedTableId, + tableListLength: tableList.length, + tableList: tableList.map(t => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId })) + }); + if (!selectedTableId) return undefined; // 먼저 tableList(필터링된 목록)에서 찾기 const tableFromList = tableList.find(t => t.tableId === selectedTableId); if (tableFromList) { + console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName); return tableFromList; } // tableList에 없으면 전체에서 찾기 (폴백) - return getTable(selectedTableId); + const tableFromAll = getTable(selectedTableId); + console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName); + return tableFromAll; }, [selectedTableId, tableList, getTable]); + // 🆕 활성 탭 ID 문자열 (변경 감지용) + const activeTabIdsStr = useMemo(() => activeTabIds.join(","), [activeTabIds]); + + // 🆕 이전 활성 탭 ID 추적 (탭 전환 감지용) + const prevActiveTabIdsRef = useRef(activeTabIdsStr); + // 대상 패널의 첫 번째 테이블 자동 선택 useEffect(() => { if (!autoSelectFirstTable || tableList.length === 0) { return; } + // 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인 + const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr; + if (tabChanged) { + console.log("🔄 [TableSearchWidget] 탭 전환 감지:", { + 이전탭: prevActiveTabIdsRef.current, + 현재탭: activeTabIdsStr, + 가용테이블: tableList.map(t => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })), + 현재선택테이블: selectedTableId + }); + prevActiveTabIdsRef.current = activeTabIdsStr; + + // 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택 + const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId)); + const targetTable = activeTabTable || tableList[0]; + + if (targetTable) { + console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", { + 테이블ID: targetTable.tableId, + 테이블명: targetTable.tableName, + 탭ID: targetTable.parentTabId, + 이전테이블: selectedTableId + }); + setSelectedTableId(targetTable.tableId); + } + return; // 탭 전환 시에는 여기서 종료 + } + // 현재 선택된 테이블이 대상 패널에 있는지 확인 const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId); - // 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택 + // 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택 if (!selectedTableId || !isCurrentTableInTarget) { - const targetTable = tableList[0]; - setSelectedTableId(targetTable.tableId); + const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId)); + const targetTable = activeTabTable || tableList[0]; + + if (targetTable && targetTable.tableId !== selectedTableId) { + console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", { + 테이블ID: targetTable.tableId, + 테이블명: targetTable.tableName, + 탭ID: targetTable.parentTabId + }); + setSelectedTableId(targetTable.tableId); + } } - }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]); + }, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition, activeTabIdsStr, activeTabIds]); // 현재 선택된 테이블의 탭 ID (탭별 필터 저장용) const currentTableTabId = currentTable?.parentTabId; @@ -196,6 +247,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table // 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드) useEffect(() => { + console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", { + currentTable: currentTable?.tableName, + currentTableTabId, + filterMode, + selectedTableId, + 컬럼수: currentTable?.columns?.length + }); if (!currentTable?.tableName) return; // 고정 모드: presetFilters를 activeFilters로 설정 @@ -229,12 +287,20 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table return; } - // 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기 + // 동적 모드: 화면별로 독립적인 필터 설정 불러오기 + // 참고: FilterPanel.tsx에서도 screenId만 사용하여 저장하므로 키가 일치해야 함 const filterConfigKey = screenId - ? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}` + ? `table_filters_${currentTable.tableName}_screen_${screenId}` : `table_filters_${currentTable.tableName}`; const savedFilters = localStorage.getItem(filterConfigKey); + console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", { + filterConfigKey, + savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null, + screenId, + tableName: currentTable.tableName + }); + if (savedFilters) { try { const parsed = JSON.parse(savedFilters) as Array<{ @@ -257,6 +323,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table width: f.width || 200, })); + console.log("📌 [TableSearchWidget] 필터 설정 로드:", { + filterConfigKey, + 총필터수: parsed.length, + 활성화필터수: activeFiltersList.length, + 활성화필터: activeFiltersList.map(f => f.columnName) + }); + setActiveFilters(activeFiltersList); // 탭별 저장된 필터 값 복원 @@ -280,10 +353,19 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table } } catch (error) { console.error("저장된 필터 불러오기 실패:", error); + // 파싱 에러 시 필터 초기화 + setActiveFilters([]); + setFilterValues({}); } } else { - // 필터 설정이 없으면 초기화 + // 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화 + console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", { + tableName: currentTable.tableName, + filterConfigKey + }); + setActiveFilters([]); setFilterValues({}); + setSelectOptions({}); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]); diff --git a/frontend/types/table-options.ts b/frontend/types/table-options.ts index 3fcdb846..bfcfccbc 100644 --- a/frontend/types/table-options.ts +++ b/frontend/types/table-options.ts @@ -66,6 +66,10 @@ export interface TableRegistration { onGroupChange: (groups: string[]) => void; onColumnVisibilityChange: (columns: ColumnVisibility[]) => void; onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경 + onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경 + + // 현재 설정 값 (읽기 전용) + frozenColumnCount?: number; // 현재 틀고정 컬럼 수 // 데이터 조회 함수 (선택 타입 필터용) getColumnUniqueValues?: (columnName: string) => Promise>;