From f03b247db279f53aa783c0af8ba43b687f398c17 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 18 Dec 2025 14:12:48 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/app.ts | 2 + .../cascadingRelationController.ts | 46 +- .../categoryValueCascadingController.ts | 927 +++++++++++++++ .../routes/categoryValueCascadingRoutes.ts | 64 ++ .../admin/cascading-management/page.tsx | 16 +- .../tabs/CategoryValueCascadingTab.tsx | 1009 +++++++++++++++++ frontend/hooks/useCascadingDropdown.ts | 224 +++- frontend/lib/api/cascadingRelation.ts | 23 +- frontend/lib/api/categoryValueCascading.ts | 255 +++++ .../select-basic/SelectBasicComponent.tsx | 201 +++- .../select-basic/SelectBasicConfigPanel.tsx | 298 ++++- 11 files changed, 2927 insertions(+), 138 deletions(-) create mode 100644 backend-node/src/controllers/categoryValueCascadingController.ts create mode 100644 backend-node/src/routes/categoryValueCascadingRoutes.ts create mode 100644 frontend/app/(main)/admin/cascading-management/tabs/CategoryValueCascadingTab.tsx create mode 100644 frontend/lib/api/categoryValueCascading.ts 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/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/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/hooks/useCascadingDropdown.ts b/frontend/hooks/useCascadingDropdown.ts index 919a92e7..69ba5a58 100644 --- a/frontend/hooks/useCascadingDropdown.ts +++ b/frontend/hooks/useCascadingDropdown.ts @@ -25,7 +25,7 @@ * }); */ -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { CascadingDropdownConfig } from "@/types/screen-management"; @@ -38,12 +38,16 @@ export interface CascadingOption { export interface UseCascadingDropdownProps { /** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */ relationCode?: string; + /** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */ + categoryRelationCode?: string; /** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */ role?: "parent" | "child"; /** @deprecated 직접 설정 방식 - relationCode 사용 권장 */ config?: CascadingDropdownConfig; - /** 부모 필드의 현재 값 (자식 역할일 때 필요) */ + /** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */ parentValue?: string | number | null; + /** 🆕 다중 부모값 (배열) - parentValue보다 우선 */ + parentValues?: (string | number)[]; /** 초기 옵션 (캐시된 데이터가 있을 경우) */ initialOptions?: CascadingOption[]; } @@ -71,9 +75,11 @@ const CACHE_TTL = 5 * 60 * 1000; // 5분 export function useCascadingDropdown({ relationCode, + categoryRelationCode, role = "child", // 기본값은 자식 역할 (기존 동작 유지) config, parentValue, + parentValues, initialOptions = [], }: UseCascadingDropdownProps): UseCascadingDropdownResult { const [options, setOptions] = useState(initialOptions); @@ -85,25 +91,50 @@ export function useCascadingDropdown({ const prevParentValueRef = useRef(undefined); // 관계 코드 또는 직접 설정 중 하나라도 있는지 확인 - const isEnabled = !!relationCode || config?.enabled; + const isEnabled = !!relationCode || !!categoryRelationCode || config?.enabled; + // 유효한 부모값 배열 계산 (다중 또는 단일) - 메모이제이션으로 불필요한 리렌더 방지 + const effectiveParentValues: string[] = useMemo(() => { + if (parentValues && parentValues.length > 0) { + return parentValues.map(v => String(v)); + } + if (parentValue !== null && parentValue !== undefined) { + return [String(parentValue)]; + } + return []; + }, [parentValues, parentValue]); + + // 부모값 배열의 문자열 키 (의존성 비교용) + const parentValuesKey = useMemo(() => JSON.stringify(effectiveParentValues), [effectiveParentValues]); + // 캐시 키 생성 const getCacheKey = useCallback(() => { + if (categoryRelationCode) { + // 카테고리 값 연쇄관계 + if (role === "parent") { + return `category-value:${categoryRelationCode}:parent:all`; + } + if (effectiveParentValues.length === 0) return null; + const sortedValues = [...effectiveParentValues].sort().join(','); + return `category-value:${categoryRelationCode}:child:${sortedValues}`; + } if (relationCode) { // 부모 역할: 전체 옵션 캐시 if (role === "parent") { return `relation:${relationCode}:parent:all`; } - // 자식 역할: 부모 값별 캐시 - if (!parentValue) return null; - return `relation:${relationCode}:child:${parentValue}`; + // 자식 역할: 부모 값별 캐시 (다중 부모값 지원) + if (effectiveParentValues.length === 0) return null; + const sortedValues = [...effectiveParentValues].sort().join(','); + return `relation:${relationCode}:child:${sortedValues}`; } if (config) { - if (!parentValue) return null; - return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`; + if (effectiveParentValues.length === 0) return null; + const sortedValues = [...effectiveParentValues].sort().join(','); + return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`; } return null; - }, [relationCode, role, config, parentValue]); + }, [categoryRelationCode, relationCode, role, config, effectiveParentValues]); // 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드) const loadParentOptions = useCallback(async () => { @@ -158,9 +189,9 @@ export function useCascadingDropdown({ } }, [relationCode, getCacheKey]); - // 자식 역할 옵션 로드 (관계 코드 방식) + // 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원 const loadChildOptions = useCallback(async () => { - if (!relationCode || !parentValue) { + if (!relationCode || effectiveParentValues.length === 0) { setOptions([]); return; } @@ -180,8 +211,18 @@ export function useCascadingDropdown({ setError(null); try { - // 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션) - const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`); + // 다중 부모값 지원: parentValues 파라미터 사용 + let url: string; + if (effectiveParentValues.length === 1) { + // 단일 값 (기존 호환) + url = `/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`; + } else { + // 다중 값 + const parentValuesParam = effectiveParentValues.join(','); + url = `/cascading-relations/options/${relationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`; + } + + const response = await apiClient.get(url); if (response.data?.success) { const loadedOptions: CascadingOption[] = response.data.data || []; @@ -195,9 +236,9 @@ export function useCascadingDropdown({ }); } - console.log("✅ Child options 로드 완료:", { + console.log("✅ Child options 로드 완료 (다중 부모값 지원):", { relationCode, - parentValue, + parentValues: effectiveParentValues, count: loadedOptions.length, }); } else { @@ -210,7 +251,121 @@ export function useCascadingDropdown({ } finally { setLoading(false); } - }, [relationCode, parentValue, getCacheKey]); + }, [relationCode, effectiveParentValues, getCacheKey]); + + // 🆕 카테고리 값 연쇄관계 - 부모 옵션 로드 + const loadCategoryParentOptions = useCallback(async () => { + if (!categoryRelationCode) { + setOptions([]); + return; + } + + const cacheKey = getCacheKey(); + + // 캐시 확인 + if (cacheKey) { + const cached = optionsCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + setOptions(cached.options); + return; + } + } + + setLoading(true); + setError(null); + + try { + const response = await apiClient.get(`/category-value-cascading/parent-options/${categoryRelationCode}`); + + if (response.data?.success) { + const loadedOptions: CascadingOption[] = response.data.data || []; + setOptions(loadedOptions); + + // 캐시 저장 + if (cacheKey) { + optionsCache.set(cacheKey, { + options: loadedOptions, + timestamp: Date.now(), + }); + } + + console.log("✅ Category parent options 로드 완료:", { + categoryRelationCode, + count: loadedOptions.length, + }); + } else { + throw new Error(response.data?.message || "옵션 로드 실패"); + } + } catch (err: any) { + console.error("❌ Category parent options 로드 실패:", err); + setError(err.message || "옵션을 불러오는 데 실패했습니다."); + setOptions([]); + } finally { + setLoading(false); + } + }, [categoryRelationCode, getCacheKey]); + + // 🆕 카테고리 값 연쇄관계 - 자식 옵션 로드 (다중 부모값 지원) + const loadCategoryChildOptions = useCallback(async () => { + if (!categoryRelationCode || effectiveParentValues.length === 0) { + setOptions([]); + return; + } + + const cacheKey = getCacheKey(); + + // 캐시 확인 + if (cacheKey) { + const cached = optionsCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + setOptions(cached.options); + return; + } + } + + setLoading(true); + setError(null); + + try { + // 다중 부모값 지원 + let url: string; + if (effectiveParentValues.length === 1) { + url = `/category-value-cascading/options/${categoryRelationCode}?parentValue=${encodeURIComponent(effectiveParentValues[0])}`; + } else { + const parentValuesParam = effectiveParentValues.join(','); + url = `/category-value-cascading/options/${categoryRelationCode}?parentValues=${encodeURIComponent(parentValuesParam)}`; + } + + const response = await apiClient.get(url); + + if (response.data?.success) { + const loadedOptions: CascadingOption[] = response.data.data || []; + setOptions(loadedOptions); + + // 캐시 저장 + if (cacheKey) { + optionsCache.set(cacheKey, { + options: loadedOptions, + timestamp: Date.now(), + }); + } + + console.log("✅ Category child options 로드 완료 (다중 부모값 지원):", { + categoryRelationCode, + parentValues: effectiveParentValues, + count: loadedOptions.length, + }); + } else { + throw new Error(response.data?.message || "옵션 로드 실패"); + } + } catch (err: any) { + console.error("❌ Category child options 로드 실패:", err); + setError(err.message || "옵션을 불러오는 데 실패했습니다."); + setOptions([]); + } finally { + setLoading(false); + } + }, [categoryRelationCode, effectiveParentValues, getCacheKey]); // 옵션 로드 (직접 설정 방식 - 레거시) const loadOptionsByConfig = useCallback(async () => { @@ -279,7 +434,14 @@ export function useCascadingDropdown({ // 통합 로드 함수 const loadOptions = useCallback(() => { - if (relationCode) { + // 카테고리 값 연쇄관계 우선 + if (categoryRelationCode) { + if (role === "parent") { + loadCategoryParentOptions(); + } else { + loadCategoryChildOptions(); + } + } else if (relationCode) { // 역할에 따라 다른 로드 함수 호출 if (role === "parent") { loadParentOptions(); @@ -291,7 +453,7 @@ export function useCascadingDropdown({ } else { setOptions([]); } - }, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]); + }, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]); // 옵션 로드 트리거 useEffect(() => { @@ -300,24 +462,28 @@ export function useCascadingDropdown({ return; } - // 부모 역할: 즉시 전체 옵션 로드 + // 부모 역할: 즉시 전체 옵션 로드 (최초 1회만) if (role === "parent") { loadOptions(); return; } // 자식 역할: 부모 값이 있을 때만 로드 - // 부모 값이 변경되었는지 확인 - const parentChanged = prevParentValueRef.current !== parentValue; - prevParentValueRef.current = parentValue; - - if (parentValue) { - loadOptions(); - } else { - // 부모 값이 없으면 옵션 초기화 - setOptions([]); + // 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교 + const prevParentKey = prevParentValueRef.current; + + if (prevParentKey !== parentValuesKey) { + prevParentValueRef.current = parentValuesKey as any; + + if (effectiveParentValues.length > 0) { + loadOptions(); + } else { + // 부모 값이 없으면 옵션 초기화 + setOptions([]); + } } - }, [isEnabled, role, parentValue, loadOptions]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEnabled, role, parentValuesKey]); // 옵션 새로고침 const refresh = useCallback(() => { diff --git a/frontend/lib/api/cascadingRelation.ts b/frontend/lib/api/cascadingRelation.ts index c27a88d0..324ef18f 100644 --- a/frontend/lib/api/cascadingRelation.ts +++ b/frontend/lib/api/cascadingRelation.ts @@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial { /** * 연쇄 관계로 자식 옵션 조회 + * 단일 부모값 또는 다중 부모값 지원 */ -export const getCascadingOptions = async (code: string, parentValue: string): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => { +export const getCascadingOptions = async ( + code: string, + parentValue: string | string[] +): Promise<{ success: boolean; data?: CascadingOption[]; error?: string }> => { try { - const response = await apiClient.get(`/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`); + let url: string; + + if (Array.isArray(parentValue)) { + // 다중 부모값: parentValues 파라미터 사용 + if (parentValue.length === 0) { + return { success: true, data: [] }; + } + const parentValuesParam = parentValue.join(','); + url = `/cascading-relations/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`; + } else { + // 단일 부모값: 기존 호환 + url = `/cascading-relations/options/${code}?parentValue=${encodeURIComponent(parentValue)}`; + } + + const response = await apiClient.get(url); return response.data; } catch (error: any) { console.error("연쇄 옵션 조회 실패:", error); diff --git a/frontend/lib/api/categoryValueCascading.ts b/frontend/lib/api/categoryValueCascading.ts new file mode 100644 index 00000000..5a2bfecb --- /dev/null +++ b/frontend/lib/api/categoryValueCascading.ts @@ -0,0 +1,255 @@ +import { apiClient } from "./client"; + +// ============================================ +// 타입 정의 +// ============================================ + +export interface CategoryValueCascadingGroup { + group_id: number; + relation_code: string; + relation_name: string; + description?: string; + parent_table_name: string; + parent_column_name: string; + parent_menu_objid?: number; + child_table_name: string; + child_column_name: string; + child_menu_objid?: number; + clear_on_parent_change?: string; + show_group_label?: string; + empty_parent_message?: string; + no_options_message?: string; + company_code: string; + is_active?: string; + created_by?: string; + created_date?: string; + updated_by?: string; + updated_date?: string; + // 상세 조회 시 포함 + mappings?: CategoryValueCascadingMapping[]; + mappingsByParent?: Record; +} + +export interface CategoryValueCascadingMapping { + mapping_id?: number; + parent_value_code: string; + parent_value_label?: string; + child_value_code: string; + child_value_label?: string; + display_order?: number; +} + +export interface CategoryValueCascadingGroupInput { + relationCode: string; + relationName: string; + description?: string; + parentTableName: string; + parentColumnName: string; + parentMenuObjid?: number; + childTableName: string; + childColumnName: string; + childMenuObjid?: number; + clearOnParentChange?: boolean; + showGroupLabel?: boolean; + emptyParentMessage?: string; + noOptionsMessage?: string; +} + +export interface CategoryValueCascadingMappingInput { + parentValueCode: string; + parentValueLabel?: string; + childValueCode: string; + childValueLabel?: string; + displayOrder?: number; +} + +export interface CategoryValueCascadingOption { + value: string; + label: string; + parent_value?: string; + parent_label?: string; + display_order?: number; +} + +// ============================================ +// API 함수 +// ============================================ + +/** + * 카테고리 값 연쇄관계 그룹 목록 조회 + */ +export const getCategoryValueCascadingGroups = async (isActive?: string) => { + try { + const params = new URLSearchParams(); + if (isActive !== undefined) { + params.append("isActive", isActive); + } + const response = await apiClient.get(`/category-value-cascading/groups?${params.toString()}`); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 그룹 목록 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 상세 조회 + */ +export const getCategoryValueCascadingGroupById = async (groupId: number) => { + try { + const response = await apiClient.get(`/category-value-cascading/groups/${groupId}`); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 그룹 상세 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 관계 코드로 조회 + */ +export const getCategoryValueCascadingByCode = async (code: string) => { + try { + const response = await apiClient.get(`/category-value-cascading/code/${code}`); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 코드 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 생성 + */ +export const createCategoryValueCascadingGroup = async (data: CategoryValueCascadingGroupInput) => { + try { + const response = await apiClient.post("/category-value-cascading/groups", data); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 그룹 생성 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 수정 + */ +export const updateCategoryValueCascadingGroup = async ( + groupId: number, + data: Partial & { isActive?: boolean } +) => { + try { + const response = await apiClient.put(`/category-value-cascading/groups/${groupId}`, data); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 그룹 수정 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 카테고리 값 연쇄관계 그룹 삭제 + */ +export const deleteCategoryValueCascadingGroup = async (groupId: number) => { + try { + const response = await apiClient.delete(`/category-value-cascading/groups/${groupId}`); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 그룹 삭제 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 매핑 일괄 저장 + */ +export const saveCategoryValueCascadingMappings = async ( + groupId: number, + mappings: CategoryValueCascadingMappingInput[] +) => { + try { + const response = await apiClient.post(`/category-value-cascading/groups/${groupId}/mappings`, { mappings }); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄관계 매핑 저장 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 연쇄 옵션 조회 (실제 드롭다운에서 사용) + * 다중 부모값 지원 + */ +export const getCategoryValueCascadingOptions = async ( + code: string, + parentValue: string | string[] +): Promise<{ success: boolean; data?: CategoryValueCascadingOption[]; showGroupLabel?: boolean; error?: string }> => { + try { + let url: string; + + if (Array.isArray(parentValue)) { + if (parentValue.length === 0) { + return { success: true, data: [] }; + } + const parentValuesParam = parentValue.join(','); + url = `/category-value-cascading/options/${code}?parentValues=${encodeURIComponent(parentValuesParam)}`; + } else { + url = `/category-value-cascading/options/${code}?parentValue=${encodeURIComponent(parentValue)}`; + } + + const response = await apiClient.get(url); + return response.data; + } catch (error: any) { + console.error("카테고리 값 연쇄 옵션 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 부모 카테고리 값 목록 조회 + */ +export const getCategoryValueCascadingParentOptions = async (code: string) => { + try { + const response = await apiClient.get(`/category-value-cascading/parent-options/${code}`); + return response.data; + } catch (error: any) { + console.error("부모 카테고리 값 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +/** + * 자식 카테고리 값 목록 조회 (매핑 설정 UI용) + */ +export const getCategoryValueCascadingChildOptions = async (code: string) => { + try { + const response = await apiClient.get(`/category-value-cascading/child-options/${code}`); + return response.data; + } catch (error: any) { + console.error("자식 카테고리 값 조회 실패:", error); + return { success: false, error: error.message }; + } +}; + +// ============================================ +// API 객체 export +// ============================================ + +export const categoryValueCascadingApi = { + // 그룹 CRUD + getGroups: getCategoryValueCascadingGroups, + getGroupById: getCategoryValueCascadingGroupById, + getByCode: getCategoryValueCascadingByCode, + createGroup: createCategoryValueCascadingGroup, + updateGroup: updateCategoryValueCascadingGroup, + deleteGroup: deleteCategoryValueCascadingGroup, + + // 매핑 + saveMappings: saveCategoryValueCascadingMappings, + + // 옵션 조회 + getOptions: getCategoryValueCascadingOptions, + getParentOptions: getCategoryValueCascadingParentOptions, + getChildOptions: getCategoryValueCascadingChildOptions, +}; + diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index 159b53d1..894b3622 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -156,22 +156,48 @@ const SelectBasicComponent: React.FC = ({ // 🆕 연쇄 드롭다운 설정 확인 const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode; + // 🆕 카테고리 값 연쇄관계 설정 + const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode; const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child"; const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField; - // 자식 역할일 때만 부모 값 필요 - const parentValue = cascadingRole === "child" && cascadingParentField && formData + + // 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중) + const rawParentValue = cascadingRole === "child" && cascadingParentField && formData ? formData[cascadingParentField] : undefined; - // 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) + // 🆕 부모값이 콤마로 구분된 문자열이면 배열로 변환 (다중 선택 지원) + const parentValues: string[] | undefined = useMemo(() => { + if (!rawParentValue) return undefined; + + // 이미 배열인 경우 + if (Array.isArray(rawParentValue)) { + return rawParentValue.map(v => String(v)).filter(v => v); + } + + // 콤마로 구분된 문자열인 경우 + const strValue = String(rawParentValue); + if (strValue.includes(',')) { + return strValue.split(',').map(v => v.trim()).filter(v => v); + } + + // 단일 값 + return [strValue]; + }, [rawParentValue]); + + // 🆕 연쇄 드롭다운 훅 사용 (역할에 따라 다른 옵션 로드) - 다중 부모값 지원 const { options: cascadingOptions, loading: isLoadingCascading, } = useCascadingDropdown({ relationCode: cascadingRelationCode, + categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원 role: cascadingRole, // 부모/자식 역할 전달 - parentValue: parentValue, + parentValues: parentValues, // 다중 부모값 }); + + // 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인 + const hasCategoryRelation = !!categoryRelationCode; useEffect(() => { if (webType === "category" && component.tableName && component.columnName) { @@ -324,6 +350,10 @@ const SelectBasicComponent: React.FC = ({ // 선택된 값에 따른 라벨 업데이트 useEffect(() => { const getAllOptionsForLabel = () => { + // 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용 + if (categoryRelationCode) { + return cascadingOptions; + } // 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용 if (cascadingRelationCode) { return cascadingOptions; @@ -353,7 +383,7 @@ const SelectBasicComponent: React.FC = ({ if (newLabel !== selectedLabel) { setSelectedLabel(newLabel); } - }, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]); + }, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode, categoryRelationCode]); // 클릭 이벤트 핸들러 (React Query로 간소화) const handleToggle = () => { @@ -404,6 +434,10 @@ const SelectBasicComponent: React.FC = ({ // 모든 옵션 가져오기 const getAllOptions = () => { + // 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용 + if (categoryRelationCode) { + return cascadingOptions; + } // 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용 if (cascadingRelationCode) { return cascadingOptions; @@ -776,50 +810,121 @@ const SelectBasicComponent: React.FC = ({ {(isLoadingCodes || isLoadingCategories) ? (
로딩 중...
) : allOptions.length > 0 ? ( - allOptions.map((option, index) => { - const isOptionSelected = selectedValues.includes(option.value); - return ( -
{ - const newVals = isOptionSelected - ? selectedValues.filter((v) => v !== option.value) - : [...selectedValues, option.value]; - setSelectedValues(newVals); - const newValue = newVals.join(","); - if (isInteractive && onFormDataChange && component.columnName) { - onFormDataChange(component.columnName, newValue); - } - }} - > -
- { - // 체크박스 직접 클릭 시에도 올바른 값으로 처리 - e.stopPropagation(); - const newVals = isOptionSelected - ? selectedValues.filter((v) => v !== option.value) - : [...selectedValues, option.value]; - setSelectedValues(newVals); - const newValue = newVals.join(","); - if (isInteractive && onFormDataChange && component.columnName) { - onFormDataChange(component.columnName, newValue); - } - }} - className="h-4 w-4 pointer-events-auto" - /> - {option.label || option.value} + (() => { + // 부모별 그룹핑 (카테고리 연쇄관계인 경우) + const hasParentInfo = allOptions.some((opt: any) => opt.parent_label); + + if (hasParentInfo) { + // 부모별로 그룹핑 + const groupedOptions: Record = {}; + allOptions.forEach((opt: any) => { + const parentKey = opt.parent_value || "기타"; + const parentLabel = opt.parent_label || "기타"; + if (!groupedOptions[parentKey]) { + groupedOptions[parentKey] = { parentLabel, options: [] }; + } + groupedOptions[parentKey].options.push(opt); + }); + + return Object.entries(groupedOptions).map(([parentKey, group]) => ( +
+ {/* 그룹 헤더 */} +
+ {group.parentLabel} +
+ {/* 그룹 옵션들 */} + {group.options.map((option, index) => { + const isOptionSelected = selectedValues.includes(option.value); + return ( +
{ + const newVals = isOptionSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + > +
+ { + e.stopPropagation(); + const newVals = isOptionSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + className="h-4 w-4 pointer-events-auto" + /> + {option.label || option.value} +
+
+ ); + })}
-
- ); - }) + )); + } + + // 부모 정보가 없으면 기존 방식 + return allOptions.map((option, index) => { + const isOptionSelected = selectedValues.includes(option.value); + return ( +
{ + const newVals = isOptionSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + > +
+ { + e.stopPropagation(); + const newVals = isOptionSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + className="h-4 w-4 pointer-events-auto" + /> + {option.label || option.value} +
+
+ ); + }); + })() ) : (
옵션이 없습니다
)} diff --git a/frontend/lib/registry/components/select-basic/SelectBasicConfigPanel.tsx b/frontend/lib/registry/components/select-basic/SelectBasicConfigPanel.tsx index f4e6d261..516a0c14 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicConfigPanel.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicConfigPanel.tsx @@ -11,6 +11,7 @@ import { Link2, ExternalLink } from "lucide-react"; import Link from "next/link"; import { SelectBasicConfig } from "./types"; import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation"; +import { categoryValueCascadingApi, CategoryValueCascadingGroup } from "@/lib/api/categoryValueCascading"; export interface SelectBasicConfigPanelProps { config: SelectBasicConfig; @@ -35,6 +36,11 @@ export const SelectBasicConfigPanel: React.FC = ({ const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode); const [relationList, setRelationList] = useState([]); const [loadingRelations, setLoadingRelations] = useState(false); + + // 🆕 카테고리 값 연쇄관계 상태 + const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode); + const [categoryRelationList, setCategoryRelationList] = useState([]); + const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false); // 연쇄 관계 목록 로드 useEffect(() => { @@ -43,10 +49,18 @@ export const SelectBasicConfigPanel: React.FC = ({ } }, [cascadingEnabled]); + // 🆕 카테고리 값 연쇄관계 목록 로드 + useEffect(() => { + if (categoryRelationEnabled && categoryRelationList.length === 0) { + loadCategoryRelationList(); + } + }, [categoryRelationEnabled]); + // config 변경 시 상태 동기화 useEffect(() => { setCascadingEnabled(!!config.cascadingRelationCode); - }, [config.cascadingRelationCode]); + setCategoryRelationEnabled(!!(config as any).categoryRelationCode); + }, [config.cascadingRelationCode, (config as any).categoryRelationCode]); const loadRelationList = async () => { setLoadingRelations(true); @@ -62,6 +76,21 @@ export const SelectBasicConfigPanel: React.FC = ({ } }; + // 🆕 카테고리 값 연쇄관계 목록 로드 + const loadCategoryRelationList = async () => { + setLoadingCategoryRelations(true); + try { + const response = await categoryValueCascadingApi.getGroups("Y"); + if (response.success && response.data) { + setCategoryRelationList(response.data); + } + } catch (error) { + console.error("카테고리 값 연쇄관계 목록 로드 실패:", error); + } finally { + setLoadingCategoryRelations(false); + } + }; + const handleChange = (key: keyof SelectBasicConfig, value: any) => { // 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호) const newConfig = { ...config, [key]: value }; @@ -82,6 +111,33 @@ export const SelectBasicConfigPanel: React.FC = ({ onChange(newConfig); } else { loadRelationList(); + // 카테고리 값 연쇄관계 비활성화 (둘 중 하나만 사용) + if (categoryRelationEnabled) { + setCategoryRelationEnabled(false); + onChange({ ...config, categoryRelationCode: undefined } as any); + } + } + }; + + // 🆕 카테고리 값 연쇄관계 토글 + const handleCategoryRelationToggle = (enabled: boolean) => { + setCategoryRelationEnabled(enabled); + if (!enabled) { + // 비활성화 시 관계 설정 제거 + const newConfig = { + ...config, + categoryRelationCode: undefined, + cascadingRole: undefined, + cascadingParentField: undefined, + } as any; + onChange(newConfig); + } else { + loadCategoryRelationList(); + // 일반 연쇄관계 비활성화 (둘 중 하나만 사용) + if (cascadingEnabled) { + setCascadingEnabled(false); + onChange({ ...config, cascadingRelationCode: undefined }); + } } }; @@ -280,52 +336,56 @@ export const SelectBasicConfigPanel: React.FC = ({ )} {/* 부모 필드 설정 (자식 역할일 때만) */} - {config.cascadingRelationCode && config.cascadingRole === "child" && ( -
- - {(() => { - const parentComp = findParentComponent(config.cascadingRelationCode); - const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName; - - return ( - <> -
- handleChange("cascadingParentField", e.target.value || undefined)} - placeholder="예: warehouse_code" - className="text-xs flex-1" - /> - {parentComp && !isAutoDetected && ( - - )} -
- {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} +

+ )} + +

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

+
+ ); + })()} + + {/* 관계 관리 페이지 링크 */} +
+ + + +
+
+ )} +
); };