카테고리 설정 구현

This commit is contained in:
kjs 2025-12-18 14:12:48 +09:00
parent 75e5326b3e
commit f03b247db2
11 changed files with 2927 additions and 138 deletions

View File

@ -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); // 임시 주석

View File

@ -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,
});

View File

@ -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<string, any[]> = {};
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,
});
}
};

View File

@ -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;

View File

@ -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() {
{/* 탭 네비게이션 */}
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-5">
<TabsList className="grid w-full grid-cols-6">
<TabsTrigger value="relations" className="gap-2">
<Link2 className="h-4 w-4" />
<span className="hidden sm:inline">2 </span>
@ -72,6 +73,11 @@ export default function CascadingManagementPage() {
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</TabsTrigger>
<TabsTrigger value="category-value" className="gap-2">
<Tags className="h-4 w-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
</TabsTrigger>
</TabsList>
{/* 탭 컨텐츠 */}
@ -95,6 +101,10 @@ export default function CascadingManagementPage() {
<TabsContent value="exclusion">
<MutualExclusionTab />
</TabsContent>
<TabsContent value="category-value">
<CategoryValueCascadingTab />
</TabsContent>
</div>
</Tabs>
</div>

File diff suppressed because it is too large Load Diff

View File

@ -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<CascadingOption[]>(initialOptions);
@ -85,25 +91,50 @@ export function useCascadingDropdown({
const prevParentValueRef = useRef<string | number | null | undefined>(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(() => {

View File

@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial<CascadingRelationC
export interface CascadingOption {
value: string;
label: string;
parent_value?: string; // 다중 부모 선택 시 어떤 부모에 속하는지 구분용
}
/**
@ -99,10 +100,28 @@ export const getCascadingRelationByCode = async (code: string) => {
/**
*
*
*/
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);

View File

@ -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<string, { childValueCode: string; childValueLabel: string; displayOrder: number }[]>;
}
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<CategoryValueCascadingGroupInput> & { 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,
};

View File

@ -156,22 +156,48 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 🆕 연쇄 드롭다운 설정 확인
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<SelectBasicComponentProps> = ({
// 선택된 값에 따른 라벨 업데이트
useEffect(() => {
const getAllOptionsForLabel = () => {
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
if (categoryRelationCode) {
return cascadingOptions;
}
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
@ -353,7 +383,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기
const getAllOptions = () => {
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
if (categoryRelationCode) {
return cascadingOptions;
}
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
if (cascadingRelationCode) {
return cascadingOptions;
@ -776,50 +810,121 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
{(isLoadingCodes || isLoadingCategories) ? (
<div className="bg-white px-3 py-2 text-gray-900"> ...</div>
) : allOptions.length > 0 ? (
allOptions.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isOptionSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
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);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isOptionSelected}
value={option.value}
onChange={(e) => {
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
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"
/>
<span>{option.label || option.value}</span>
(() => {
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
if (hasParentInfo) {
// 부모별로 그룹핑
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
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]) => (
<div key={parentKey}>
{/* 그룹 헤더 */}
<div className="sticky top-0 bg-gray-100 px-3 py-1.5 text-xs font-semibold text-gray-600 border-b">
{group.parentLabel}
</div>
{/* 그룹 옵션들 */}
{group.options.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isOptionSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
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);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isOptionSelected}
value={option.value}
onChange={(e) => {
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"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
})}
</div>
</div>
);
})
));
}
// 부모 정보가 없으면 기존 방식
return allOptions.map((option, index) => {
const isOptionSelected = selectedValues.includes(option.value);
return (
<div
key={`${option.value}-${index}`}
className={cn(
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
isOptionSelected && "bg-blue-50 font-medium"
)}
onClick={() => {
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);
}
}}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isOptionSelected}
value={option.value}
onChange={(e) => {
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"
/>
<span>{option.label || option.value}</span>
</div>
</div>
);
});
})()
) : (
<div className="bg-white px-3 py-2 text-gray-900"> </div>
)}

View File

@ -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<SelectBasicConfigPanelProps> = ({
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
const [loadingRelations, setLoadingRelations] = useState(false);
// 🆕 카테고리 값 연쇄관계 상태
const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode);
const [categoryRelationList, setCategoryRelationList] = useState<CategoryValueCascadingGroup[]>([]);
const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false);
// 연쇄 관계 목록 로드
useEffect(() => {
@ -43,10 +49,18 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
}
}, [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<SelectBasicConfigPanelProps> = ({
}
};
// 🆕 카테고리 값 연쇄관계 목록 로드
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<SelectBasicConfigPanelProps> = ({
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<SelectBasicConfigPanelProps> = ({
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{config.cascadingRelationCode && config.cascadingRole === "child" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{(() => {
const parentComp = findParentComponent(config.cascadingRelationCode);
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
return (
<>
<div className="flex gap-2 items-center">
<Input
value={config.cascadingParentField || ""}
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
placeholder="예: warehouse_code"
className="text-xs flex-1"
/>
{parentComp && !isAutoDetected && (
<Button
type="button"
size="sm"
variant="outline"
className="text-xs shrink-0"
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
>
</Button>
)}
</div>
{isAutoDetected ? (
<p className="text-xs text-green-600">
: {parentComp.label || parentComp.columnName}
</p>
) : parentComp ? (
<p className="text-xs text-amber-600">
: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
</p>
) : (
<p className="text-muted-foreground text-xs">
. .
</p>
{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 (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{expectedParentColumn && (
<p className="text-muted-foreground text-xs">
: <strong>{expectedParentColumn}</strong>
</p>
)}
<Select
value={config.cascadingParentField || ""}
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder="부모 필드 선택" />
</SelectTrigger>
<SelectContent>
{parentFieldCandidates.map((comp) => (
<SelectItem key={comp.id} value={comp.columnName}>
{comp.label || comp.columnName} ({comp.columnName})
</SelectItem>
))}
{parentFieldCandidates.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{expectedParentColumn
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
: "선택 가능한 부모 필드가 없습니다"}
</div>
)}
</>
);
})()}
</div>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
);
})()}
{/* 선택된 관계 정보 표시 */}
{selectedRelation && config.cascadingRole && (
@ -374,6 +434,152 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
</div>
)}
</div>
{/* 🆕 카테고리 값 연쇄관계 설정 */}
<div className="border-t pt-4 mt-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Link2 className="h-4 w-4" />
<Label className="text-sm font-medium"> </Label>
</div>
<Switch
checked={categoryRelationEnabled}
onCheckedChange={handleCategoryRelationToggle}
/>
</div>
<p className="text-muted-foreground text-xs">
.
<br />: 검사유형
</p>
{categoryRelationEnabled && (
<div className="space-y-3 rounded-md border p-3 bg-muted/30">
{/* 관계 선택 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={(config as any).categoryRelationCode || ""}
onValueChange={(value) => handleChange("categoryRelationCode" as any, value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder={loadingCategoryRelations ? "로딩 중..." : "관계 선택"} />
</SelectTrigger>
<SelectContent>
{categoryRelationList.map((relation) => (
<SelectItem key={relation.relation_code} value={relation.relation_code}>
<div className="flex flex-col">
<span>{relation.relation_name}</span>
<span className="text-muted-foreground text-xs">
{relation.parent_table_name}.{relation.parent_column_name} {relation.child_table_name}.{relation.child_column_name}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 역할 선택 */}
{(config as any).categoryRelationCode && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={config.cascadingRole === "parent" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("parent")}
>
( )
</Button>
<Button
type="button"
size="sm"
variant={config.cascadingRole === "child" ? "default" : "outline"}
className="flex-1 text-xs"
onClick={() => handleRoleChange("child")}
>
( )
</Button>
</div>
<p className="text-muted-foreground text-xs">
{config.cascadingRole === "parent"
? "이 필드가 상위 카테고리 선택 역할을 합니다. (예: 검사유형)"
: config.cascadingRole === "child"
? "이 필드는 상위 카테고리 값에 따라 옵션이 변경됩니다. (예: 적용대상)"
: "이 필드의 역할을 선택하세요."}
</p>
</div>
)}
{/* 부모 필드 설정 (자식 역할일 때만) */}
{(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 (
<div className="space-y-2">
<Label className="text-xs"> </Label>
{expectedParentColumn && (
<p className="text-muted-foreground text-xs">
: <strong>{expectedParentColumn}</strong>
</p>
)}
<Select
value={config.cascadingParentField || ""}
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
>
<SelectTrigger className="text-xs">
<SelectValue placeholder="부모 필드 선택" />
</SelectTrigger>
<SelectContent>
{parentFieldCandidates.map((comp) => (
<SelectItem key={comp.id} value={comp.columnName}>
{comp.label || comp.columnName} ({comp.columnName})
</SelectItem>
))}
{parentFieldCandidates.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{expectedParentColumn
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
: "선택 가능한 부모 필드가 없습니다"}
</div>
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
.
</p>
</div>
);
})()}
{/* 관계 관리 페이지 링크 */}
<div className="flex justify-end">
<Link href="/admin/cascading-management?tab=category-value" target="_blank">
<Button variant="link" size="sm" className="h-auto p-0 text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
</Button>
</Link>
</div>
</div>
)}
</div>
</div>
);
};