Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into reportMng
This commit is contained in:
commit
a617c26721
|
|
@ -80,6 +80,7 @@ import cascadingAutoFillRoutes from "./routes/cascadingAutoFillRoutes"; // 자
|
||||||
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
import cascadingConditionRoutes from "./routes/cascadingConditionRoutes"; // 조건부 연쇄 관리
|
||||||
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
import cascadingMutualExclusionRoutes from "./routes/cascadingMutualExclusionRoutes"; // 상호 배제 관리
|
||||||
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다단계 계층 관리
|
||||||
|
import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// 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-conditions", cascadingConditionRoutes); // 조건부 연쇄 관리
|
||||||
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
app.use("/api/cascading-exclusions", cascadingMutualExclusionRoutes); // 상호 배제 관리
|
||||||
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계층 관리
|
||||||
|
app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계
|
||||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -3394,13 +3394,23 @@ export async function copyMenu(
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
|
||||||
|
const additionalCopyOptions = req.body.additionalCopyOptions
|
||||||
|
? {
|
||||||
|
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
|
||||||
|
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
|
||||||
|
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 메뉴 복사 실행
|
// 메뉴 복사 실행
|
||||||
const menuCopyService = new MenuCopyService();
|
const menuCopyService = new MenuCopyService();
|
||||||
const result = await menuCopyService.copyMenu(
|
const result = await menuCopyService.copyMenu(
|
||||||
parseInt(menuObjid, 10),
|
parseInt(menuObjid, 10),
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
userId,
|
userId,
|
||||||
screenNameConfig
|
screenNameConfig,
|
||||||
|
additionalCopyOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.info("✅ 메뉴 복사 API 성공");
|
logger.info("✅ 메뉴 복사 API 성공");
|
||||||
|
|
|
||||||
|
|
@ -662,6 +662,10 @@ export const getParentOptions = async (
|
||||||
/**
|
/**
|
||||||
* 연쇄 관계로 자식 옵션 조회
|
* 연쇄 관계로 자식 옵션 조회
|
||||||
* 실제 연쇄 드롭다운에서 사용하는 API
|
* 실제 연쇄 드롭다운에서 사용하는 API
|
||||||
|
*
|
||||||
|
* 다중 부모값 지원:
|
||||||
|
* - parentValue: 단일 값 (예: "공정검사")
|
||||||
|
* - parentValues: 다중 값 (예: "공정검사,출하검사" 또는 배열)
|
||||||
*/
|
*/
|
||||||
export const getCascadingOptions = async (
|
export const getCascadingOptions = async (
|
||||||
req: AuthenticatedRequest,
|
req: AuthenticatedRequest,
|
||||||
|
|
@ -669,10 +673,26 @@ export const getCascadingOptions = async (
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
const { parentValue } = req.query;
|
const { parentValue, parentValues } = req.query;
|
||||||
const companyCode = req.user?.companyCode || "*";
|
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({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: [],
|
data: [],
|
||||||
|
|
@ -714,13 +734,17 @@ export const getCascadingOptions = async (
|
||||||
|
|
||||||
const relation = relationResult.rows[0];
|
const relation = relationResult.rows[0];
|
||||||
|
|
||||||
// 자식 옵션 조회
|
// 자식 옵션 조회 - 다중 부모값에 대해 IN 절 사용
|
||||||
|
// SQL Injection 방지를 위해 파라미터화된 쿼리 사용
|
||||||
|
const placeholders = parentValueArray.map((_, idx) => `$${idx + 1}`).join(', ');
|
||||||
|
|
||||||
let optionsQuery = `
|
let optionsQuery = `
|
||||||
SELECT
|
SELECT DISTINCT
|
||||||
${relation.child_value_column} as value,
|
${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}
|
FROM ${relation.child_table}
|
||||||
WHERE ${relation.child_filter_column} = $1
|
WHERE ${relation.child_filter_column} IN (${placeholders})
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
// 멀티테넌시 적용 (테이블에 company_code가 있는 경우)
|
||||||
|
|
@ -730,7 +754,8 @@ export const getCascadingOptions = async (
|
||||||
[relation.child_table]
|
[relation.child_table]
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionsParams: any[] = [parentValue];
|
const optionsParams: any[] = [...parentValueArray];
|
||||||
|
let paramIndex = parentValueArray.length + 1;
|
||||||
|
|
||||||
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
// company_code = "*"는 최고 관리자 전용이므로 일반 회사는 자기 회사 데이터만
|
||||||
if (
|
if (
|
||||||
|
|
@ -738,8 +763,9 @@ export const getCascadingOptions = async (
|
||||||
tableInfoResult.rowCount > 0 &&
|
tableInfoResult.rowCount > 0 &&
|
||||||
companyCode !== "*"
|
companyCode !== "*"
|
||||||
) {
|
) {
|
||||||
optionsQuery += ` AND company_code = $2`;
|
optionsQuery += ` AND company_code = $${paramIndex}`;
|
||||||
optionsParams.push(companyCode);
|
optionsParams.push(companyCode);
|
||||||
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 정렬
|
// 정렬
|
||||||
|
|
@ -751,9 +777,9 @@ export const getCascadingOptions = async (
|
||||||
|
|
||||||
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
const optionsResult = await pool.query(optionsQuery, optionsParams);
|
||||||
|
|
||||||
logger.info("연쇄 옵션 조회", {
|
logger.info("연쇄 옵션 조회 (다중 부모값 지원)", {
|
||||||
relationCode: code,
|
relationCode: code,
|
||||||
parentValue,
|
parentValues: parentValueArray,
|
||||||
optionsCount: optionsResult.rowCount,
|
optionsCount: optionsResult.rowCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,7 +3,7 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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";
|
import CascadingRelationsTab from "./tabs/CascadingRelationsTab";
|
||||||
|
|
@ -11,6 +11,7 @@ import AutoFillTab from "./tabs/AutoFillTab";
|
||||||
import HierarchyTab from "./tabs/HierarchyTab";
|
import HierarchyTab from "./tabs/HierarchyTab";
|
||||||
import ConditionTab from "./tabs/ConditionTab";
|
import ConditionTab from "./tabs/ConditionTab";
|
||||||
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
import MutualExclusionTab from "./tabs/MutualExclusionTab";
|
||||||
|
import CategoryValueCascadingTab from "./tabs/CategoryValueCascadingTab";
|
||||||
|
|
||||||
export default function CascadingManagementPage() {
|
export default function CascadingManagementPage() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -20,7 +21,7 @@ export default function CascadingManagementPage() {
|
||||||
// URL 쿼리 파라미터에서 탭 설정
|
// URL 쿼리 파라미터에서 탭 설정
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const tab = searchParams.get("tab");
|
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);
|
setActiveTab(tab);
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
@ -46,7 +47,7 @@ export default function CascadingManagementPage() {
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 */}
|
||||||
<Tabs value={activeTab} onValueChange={handleTabChange} className="w-full">
|
<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">
|
<TabsTrigger value="relations" className="gap-2">
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
<span className="hidden sm:inline">2단계 연쇄관계</span>
|
||||||
|
|
@ -72,6 +73,11 @@ export default function CascadingManagementPage() {
|
||||||
<span className="hidden sm:inline">상호 배제</span>
|
<span className="hidden sm:inline">상호 배제</span>
|
||||||
<span className="sm:hidden">배제</span>
|
<span className="sm:hidden">배제</span>
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
{/* 탭 컨텐츠 */}
|
{/* 탭 컨텐츠 */}
|
||||||
|
|
@ -95,6 +101,10 @@ export default function CascadingManagementPage() {
|
||||||
<TabsContent value="exclusion">
|
<TabsContent value="exclusion">
|
||||||
<MutualExclusionTab />
|
<MutualExclusionTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="category-value">
|
||||||
|
<CategoryValueCascadingTab />
|
||||||
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -56,6 +56,12 @@ export function MenuCopyDialog({
|
||||||
const [removeText, setRemoveText] = useState("");
|
const [removeText, setRemoveText] = useState("");
|
||||||
const [addPrefix, setAddPrefix] = useState("");
|
const [addPrefix, setAddPrefix] = useState("");
|
||||||
|
|
||||||
|
// 카테고리/코드 복사 옵션
|
||||||
|
const [copyCodeCategory, setCopyCodeCategory] = useState(false);
|
||||||
|
const [copyNumberingRules, setCopyNumberingRules] = useState(false);
|
||||||
|
const [copyCategoryMapping, setCopyCategoryMapping] = useState(false);
|
||||||
|
const [copyTableTypeColumns, setCopyTableTypeColumns] = useState(false);
|
||||||
|
|
||||||
// 회사 목록 로드
|
// 회사 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
|
@ -66,6 +72,10 @@ export function MenuCopyDialog({
|
||||||
setUseBulkRename(false);
|
setUseBulkRename(false);
|
||||||
setRemoveText("");
|
setRemoveText("");
|
||||||
setAddPrefix("");
|
setAddPrefix("");
|
||||||
|
setCopyCodeCategory(false);
|
||||||
|
setCopyNumberingRules(false);
|
||||||
|
setCopyCategoryMapping(false);
|
||||||
|
setCopyTableTypeColumns(false);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
|
@ -112,10 +122,19 @@ export function MenuCopyDialog({
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// 추가 복사 옵션
|
||||||
|
const additionalCopyOptions = {
|
||||||
|
copyCodeCategory,
|
||||||
|
copyNumberingRules,
|
||||||
|
copyCategoryMapping,
|
||||||
|
copyTableTypeColumns,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await menuApi.copyMenu(
|
const response = await menuApi.copyMenu(
|
||||||
menuObjid,
|
menuObjid,
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
screenNameConfig
|
screenNameConfig,
|
||||||
|
additionalCopyOptions
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
|
|
@ -264,19 +283,82 @@ export function MenuCopyDialog({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 추가 복사 옵션 */}
|
||||||
|
{!result && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-medium">추가 복사 옵션 (선택사항):</p>
|
||||||
|
<div className="space-y-2 pl-2 border-l-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="copyCodeCategory"
|
||||||
|
checked={copyCodeCategory}
|
||||||
|
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
|
||||||
|
disabled={copying}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="copyCodeCategory"
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
코드 카테고리 + 코드 복사
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="copyNumberingRules"
|
||||||
|
checked={copyNumberingRules}
|
||||||
|
onCheckedChange={(checked) => setCopyNumberingRules(checked as boolean)}
|
||||||
|
disabled={copying}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="copyNumberingRules"
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
채번 규칙 복사
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="copyCategoryMapping"
|
||||||
|
checked={copyCategoryMapping}
|
||||||
|
onCheckedChange={(checked) => setCopyCategoryMapping(checked as boolean)}
|
||||||
|
disabled={copying}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="copyCategoryMapping"
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
카테고리 매핑 + 값 복사
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="copyTableTypeColumns"
|
||||||
|
checked={copyTableTypeColumns}
|
||||||
|
onCheckedChange={(checked) => setCopyTableTypeColumns(checked as boolean)}
|
||||||
|
disabled={copying}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="copyTableTypeColumns"
|
||||||
|
className="text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
테이블 타입관리 입력타입 설정 복사
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 복사 항목 안내 */}
|
{/* 복사 항목 안내 */}
|
||||||
{!result && (
|
{!result && (
|
||||||
<div className="rounded-md border p-3 text-xs">
|
<div className="rounded-md border p-3 text-xs">
|
||||||
<p className="font-medium mb-2">복사되는 항목:</p>
|
<p className="font-medium mb-2">기본 복사 항목:</p>
|
||||||
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
<ul className="list-disc list-inside space-y-1 text-muted-foreground">
|
||||||
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
<li>메뉴 구조 (하위 메뉴 포함)</li>
|
||||||
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
<li>화면 + 레이아웃 (모달, 조건부 컨테이너)</li>
|
||||||
<li>플로우 제어 (스텝, 연결)</li>
|
<li>플로우 제어 (스텝, 연결)</li>
|
||||||
<li>코드 카테고리 + 코드</li>
|
|
||||||
<li>카테고리 설정 + 채번 규칙</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
<p className="mt-2 text-warning">
|
<p className="mt-2 text-muted-foreground">
|
||||||
⚠️ 실제 데이터는 복사되지 않습니다.
|
* 코드, 채번규칙, 카테고리는 위 옵션 선택 시 복사됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -294,10 +376,40 @@ export function MenuCopyDialog({
|
||||||
<span className="text-muted-foreground">화면:</span>{" "}
|
<span className="text-muted-foreground">화면:</span>{" "}
|
||||||
<span className="font-medium">{result.copiedScreens}개</span>
|
<span className="font-medium">{result.copiedScreens}개</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2">
|
<div>
|
||||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||||
<span className="font-medium">{result.copiedFlows}개</span>
|
<span className="font-medium">{result.copiedFlows}개</span>
|
||||||
</div>
|
</div>
|
||||||
|
{(result.copiedCodeCategories ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedCodeCategories}개</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(result.copiedCodes ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">코드:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedCodes}개</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(result.copiedNumberingRules ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">채번규칙:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedNumberingRules}개</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(result.copiedCategoryMappings ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">카테고리 매핑:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedCategoryMappings}개</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(result.copiedTableTypeColumns ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-muted-foreground">테이블 타입 설정:</span>{" "}
|
||||||
|
<span className="font-medium">{result.copiedTableTypeColumns}개</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { GripVertical, Eye, EyeOff } from "lucide-react";
|
import { GripVertical, Eye, EyeOff, Lock } from "lucide-react";
|
||||||
import { ColumnVisibility } from "@/types/table-options";
|
import { ColumnVisibility } from "@/types/table-options";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -30,6 +30,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
|
|
||||||
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
const [localColumns, setLocalColumns] = useState<ColumnVisibility[]>([]);
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||||
|
|
||||||
// 테이블 정보 로드
|
// 테이블 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -42,6 +43,8 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
order: 0,
|
order: 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
// 현재 틀고정 컬럼 수 로드
|
||||||
|
setFrozenColumnCount(table.frozenColumnCount ?? 0);
|
||||||
}
|
}
|
||||||
}, [table]);
|
}, [table]);
|
||||||
|
|
||||||
|
|
@ -94,6 +97,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
table.onColumnOrderChange(newOrder);
|
table.onColumnOrderChange(newOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 틀고정 컬럼 수 변경 콜백 호출
|
||||||
|
if (table?.onFrozenColumnCountChange) {
|
||||||
|
table.onFrozenColumnCountChange(frozenColumnCount);
|
||||||
|
}
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -107,9 +115,18 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
order: 0,
|
order: 0,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
setFrozenColumnCount(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 틀고정 컬럼 수 변경 핸들러
|
||||||
|
const handleFrozenColumnCountChange = (value: string) => {
|
||||||
|
const count = parseInt(value) || 0;
|
||||||
|
// 최대값은 표시 가능한 컬럼 수
|
||||||
|
const maxCount = localColumns.filter((col) => col.visible).length;
|
||||||
|
setFrozenColumnCount(Math.min(Math.max(0, count), maxCount));
|
||||||
|
};
|
||||||
|
|
||||||
const visibleCount = localColumns.filter((col) => col.visible).length;
|
const visibleCount = localColumns.filter((col) => col.visible).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -126,11 +143,34 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{/* 상태 표시 */}
|
{/* 상태 표시 및 틀고정 설정 */}
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-muted/50 p-3">
|
<div className="flex flex-col gap-3 rounded-lg border bg-muted/50 p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="text-xs text-muted-foreground sm:text-sm">
|
<div className="flex items-center gap-4">
|
||||||
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
<div className="text-xs text-muted-foreground sm:text-sm">
|
||||||
|
{visibleCount}/{localColumns.length}개 컬럼 표시 중
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 틀고정 설정 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Lock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<Label className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
틀고정:
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={frozenColumnCount}
|
||||||
|
onChange={(e) => handleFrozenColumnCountChange(e.target.value)}
|
||||||
|
className="h-7 w-16 text-xs sm:h-8 sm:w-20 sm:text-sm"
|
||||||
|
min={0}
|
||||||
|
max={visibleCount}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
개 컬럼
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -148,6 +188,12 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
const columnMeta = table?.columns.find(
|
const columnMeta = table?.columns.find(
|
||||||
(c) => c.columnName === col.columnName
|
(c) => c.columnName === col.columnName
|
||||||
);
|
);
|
||||||
|
// 표시 가능한 컬럼 중 몇 번째인지 계산 (틀고정 표시용)
|
||||||
|
const visibleIndex = localColumns
|
||||||
|
.slice(0, index + 1)
|
||||||
|
.filter((c) => c.visible).length;
|
||||||
|
const isFrozen = col.visible && visibleIndex <= frozenColumnCount;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={col.columnName}
|
key={col.columnName}
|
||||||
|
|
@ -155,7 +201,11 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
onDragStart={() => handleDragStart(index)}
|
onDragStart={() => handleDragStart(index)}
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50 cursor-move"
|
className={`flex items-center gap-3 rounded-lg border p-3 transition-colors cursor-move ${
|
||||||
|
isFrozen
|
||||||
|
? "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800"
|
||||||
|
: "bg-background hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{/* 드래그 핸들 */}
|
{/* 드래그 핸들 */}
|
||||||
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -171,8 +221,10 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 가시성 아이콘 */}
|
{/* 가시성/틀고정 아이콘 */}
|
||||||
{col.visible ? (
|
{isFrozen ? (
|
||||||
|
<Lock className="h-4 w-4 shrink-0 text-blue-500" />
|
||||||
|
) : col.visible ? (
|
||||||
<Eye className="h-4 w-4 shrink-0 text-primary" />
|
<Eye className="h-4 w-4 shrink-0 text-primary" />
|
||||||
) : (
|
) : (
|
||||||
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<EyeOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
|
@ -180,8 +232,15 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
|
|
||||||
{/* 컬럼명 */}
|
{/* 컬럼명 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-xs font-medium sm:text-sm">
|
<div className="flex items-center gap-2">
|
||||||
{columnMeta?.columnLabel}
|
<span className="text-xs font-medium sm:text-sm">
|
||||||
|
{columnMeta?.columnLabel}
|
||||||
|
</span>
|
||||||
|
{isFrozen && (
|
||||||
|
<span className="text-[10px] text-blue-600 dark:text-blue-400 font-medium">
|
||||||
|
(고정)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
<div className="text-[10px] text-muted-foreground sm:text-xs">
|
||||||
{col.columnName}
|
{col.columnName}
|
||||||
|
|
@ -217,7 +276,7 @@ export const ColumnVisibilityPanel: React.FC<Props> = ({
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => onOpenChange(false)}
|
onClick={onClose}
|
||||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
|
|
|
||||||
|
|
@ -43,25 +43,24 @@ export const TableOptionsProvider: React.FC<{ children: ReactNode }> = ({
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 등록 해제
|
* 테이블 등록 해제
|
||||||
|
* 주의:
|
||||||
|
* 1. selectedTableId를 의존성으로 사용하면 무한 루프 발생 가능
|
||||||
|
* 2. 재등록 시에도 unregister가 호출되므로 selectedTableId를 변경하면 안됨
|
||||||
*/
|
*/
|
||||||
const unregisterTable = useCallback(
|
const unregisterTable = useCallback(
|
||||||
(tableId: string) => {
|
(tableId: string) => {
|
||||||
setRegisteredTables((prev) => {
|
setRegisteredTables((prev) => {
|
||||||
const newMap = new Map(prev);
|
const newMap = new Map(prev);
|
||||||
const removed = newMap.delete(tableId);
|
newMap.delete(tableId);
|
||||||
|
|
||||||
if (removed) {
|
|
||||||
// 선택된 테이블이 제거되면 첫 번째 테이블 선택
|
|
||||||
if (selectedTableId === tableId) {
|
|
||||||
const firstTableId = newMap.keys().next().value;
|
|
||||||
setSelectedTableId(firstTableId || null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newMap;
|
return newMap;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🚫 selectedTableId를 변경하지 않음
|
||||||
|
// 이유: useEffect 재실행 시 cleanup → register 순서로 호출되는데,
|
||||||
|
// cleanup에서 selectedTableId를 null로 만들면 필터 설정이 초기화됨
|
||||||
|
// 다른 테이블이 선택되어야 하면 TableSearchWidget에서 자동 선택함
|
||||||
},
|
},
|
||||||
[selectedTableId]
|
[] // 의존성 없음 - 무한 루프 방지
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 { apiClient } from "@/lib/api/client";
|
||||||
import { CascadingDropdownConfig } from "@/types/screen-management";
|
import { CascadingDropdownConfig } from "@/types/screen-management";
|
||||||
|
|
||||||
|
|
@ -38,12 +38,16 @@ export interface CascadingOption {
|
||||||
export interface UseCascadingDropdownProps {
|
export interface UseCascadingDropdownProps {
|
||||||
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
/** 🆕 연쇄 관계 코드 (관계 관리에서 정의한 코드) */
|
||||||
relationCode?: string;
|
relationCode?: string;
|
||||||
|
/** 🆕 카테고리 값 연쇄 관계 코드 (카테고리 값 연쇄관계용) */
|
||||||
|
categoryRelationCode?: string;
|
||||||
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
/** 🆕 역할: parent(부모-전체 옵션) 또는 child(자식-필터링된 옵션) */
|
||||||
role?: "parent" | "child";
|
role?: "parent" | "child";
|
||||||
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
/** @deprecated 직접 설정 방식 - relationCode 사용 권장 */
|
||||||
config?: CascadingDropdownConfig;
|
config?: CascadingDropdownConfig;
|
||||||
/** 부모 필드의 현재 값 (자식 역할일 때 필요) */
|
/** 부모 필드의 현재 값 (자식 역할일 때 필요) - 단일 값 또는 배열(다중 선택) */
|
||||||
parentValue?: string | number | null;
|
parentValue?: string | number | null;
|
||||||
|
/** 🆕 다중 부모값 (배열) - parentValue보다 우선 */
|
||||||
|
parentValues?: (string | number)[];
|
||||||
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
/** 초기 옵션 (캐시된 데이터가 있을 경우) */
|
||||||
initialOptions?: CascadingOption[];
|
initialOptions?: CascadingOption[];
|
||||||
}
|
}
|
||||||
|
|
@ -71,9 +75,11 @@ const CACHE_TTL = 5 * 60 * 1000; // 5분
|
||||||
|
|
||||||
export function useCascadingDropdown({
|
export function useCascadingDropdown({
|
||||||
relationCode,
|
relationCode,
|
||||||
|
categoryRelationCode,
|
||||||
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
role = "child", // 기본값은 자식 역할 (기존 동작 유지)
|
||||||
config,
|
config,
|
||||||
parentValue,
|
parentValue,
|
||||||
|
parentValues,
|
||||||
initialOptions = [],
|
initialOptions = [],
|
||||||
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
}: UseCascadingDropdownProps): UseCascadingDropdownResult {
|
||||||
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
const [options, setOptions] = useState<CascadingOption[]>(initialOptions);
|
||||||
|
|
@ -85,25 +91,50 @@ export function useCascadingDropdown({
|
||||||
const prevParentValueRef = useRef<string | number | null | undefined>(undefined);
|
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(() => {
|
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 (relationCode) {
|
||||||
// 부모 역할: 전체 옵션 캐시
|
// 부모 역할: 전체 옵션 캐시
|
||||||
if (role === "parent") {
|
if (role === "parent") {
|
||||||
return `relation:${relationCode}:parent:all`;
|
return `relation:${relationCode}:parent:all`;
|
||||||
}
|
}
|
||||||
// 자식 역할: 부모 값별 캐시
|
// 자식 역할: 부모 값별 캐시 (다중 부모값 지원)
|
||||||
if (!parentValue) return null;
|
if (effectiveParentValues.length === 0) return null;
|
||||||
return `relation:${relationCode}:child:${parentValue}`;
|
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||||
|
return `relation:${relationCode}:child:${sortedValues}`;
|
||||||
}
|
}
|
||||||
if (config) {
|
if (config) {
|
||||||
if (!parentValue) return null;
|
if (effectiveParentValues.length === 0) return null;
|
||||||
return `${config.sourceTable}:${config.parentKeyColumn}:${parentValue}`;
|
const sortedValues = [...effectiveParentValues].sort().join(',');
|
||||||
|
return `${config.sourceTable}:${config.parentKeyColumn}:${sortedValues}`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [relationCode, role, config, parentValue]);
|
}, [categoryRelationCode, relationCode, role, config, effectiveParentValues]);
|
||||||
|
|
||||||
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
// 🆕 부모 역할 옵션 로드 (관계의 parent_table에서 전체 옵션 로드)
|
||||||
const loadParentOptions = useCallback(async () => {
|
const loadParentOptions = useCallback(async () => {
|
||||||
|
|
@ -158,9 +189,9 @@ export function useCascadingDropdown({
|
||||||
}
|
}
|
||||||
}, [relationCode, getCacheKey]);
|
}, [relationCode, getCacheKey]);
|
||||||
|
|
||||||
// 자식 역할 옵션 로드 (관계 코드 방식)
|
// 자식 역할 옵션 로드 (관계 코드 방식) - 다중 부모값 지원
|
||||||
const loadChildOptions = useCallback(async () => {
|
const loadChildOptions = useCallback(async () => {
|
||||||
if (!relationCode || !parentValue) {
|
if (!relationCode || effectiveParentValues.length === 0) {
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -180,8 +211,18 @@ export function useCascadingDropdown({
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 관계 코드로 옵션 조회 API 호출 (자식 역할 - 필터링된 옵션)
|
// 다중 부모값 지원: parentValues 파라미터 사용
|
||||||
const response = await apiClient.get(`/cascading-relations/options/${relationCode}?parentValue=${encodeURIComponent(String(parentValue))}`);
|
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) {
|
if (response.data?.success) {
|
||||||
const loadedOptions: CascadingOption[] = response.data.data || [];
|
const loadedOptions: CascadingOption[] = response.data.data || [];
|
||||||
|
|
@ -195,9 +236,9 @@ export function useCascadingDropdown({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("✅ Child options 로드 완료:", {
|
console.log("✅ Child options 로드 완료 (다중 부모값 지원):", {
|
||||||
relationCode,
|
relationCode,
|
||||||
parentValue,
|
parentValues: effectiveParentValues,
|
||||||
count: loadedOptions.length,
|
count: loadedOptions.length,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -210,7 +251,121 @@ export function useCascadingDropdown({
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 () => {
|
const loadOptionsByConfig = useCallback(async () => {
|
||||||
|
|
@ -279,7 +434,14 @@ export function useCascadingDropdown({
|
||||||
|
|
||||||
// 통합 로드 함수
|
// 통합 로드 함수
|
||||||
const loadOptions = useCallback(() => {
|
const loadOptions = useCallback(() => {
|
||||||
if (relationCode) {
|
// 카테고리 값 연쇄관계 우선
|
||||||
|
if (categoryRelationCode) {
|
||||||
|
if (role === "parent") {
|
||||||
|
loadCategoryParentOptions();
|
||||||
|
} else {
|
||||||
|
loadCategoryChildOptions();
|
||||||
|
}
|
||||||
|
} else if (relationCode) {
|
||||||
// 역할에 따라 다른 로드 함수 호출
|
// 역할에 따라 다른 로드 함수 호출
|
||||||
if (role === "parent") {
|
if (role === "parent") {
|
||||||
loadParentOptions();
|
loadParentOptions();
|
||||||
|
|
@ -291,7 +453,7 @@ export function useCascadingDropdown({
|
||||||
} else {
|
} else {
|
||||||
setOptions([]);
|
setOptions([]);
|
||||||
}
|
}
|
||||||
}, [relationCode, role, config?.enabled, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
}, [categoryRelationCode, relationCode, role, config?.enabled, loadCategoryParentOptions, loadCategoryChildOptions, loadParentOptions, loadChildOptions, loadOptionsByConfig]);
|
||||||
|
|
||||||
// 옵션 로드 트리거
|
// 옵션 로드 트리거
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -300,24 +462,28 @@ export function useCascadingDropdown({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 부모 역할: 즉시 전체 옵션 로드
|
// 부모 역할: 즉시 전체 옵션 로드 (최초 1회만)
|
||||||
if (role === "parent") {
|
if (role === "parent") {
|
||||||
loadOptions();
|
loadOptions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자식 역할: 부모 값이 있을 때만 로드
|
// 자식 역할: 부모 값이 있을 때만 로드
|
||||||
// 부모 값이 변경되었는지 확인
|
// 부모 값 배열의 변경 감지를 위해 JSON 문자열 비교
|
||||||
const parentChanged = prevParentValueRef.current !== parentValue;
|
const prevParentKey = prevParentValueRef.current;
|
||||||
prevParentValueRef.current = parentValue;
|
|
||||||
|
if (prevParentKey !== parentValuesKey) {
|
||||||
if (parentValue) {
|
prevParentValueRef.current = parentValuesKey as any;
|
||||||
loadOptions();
|
|
||||||
} else {
|
if (effectiveParentValues.length > 0) {
|
||||||
// 부모 값이 없으면 옵션 초기화
|
loadOptions();
|
||||||
setOptions([]);
|
} else {
|
||||||
|
// 부모 값이 없으면 옵션 초기화
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isEnabled, role, parentValue, loadOptions]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isEnabled, role, parentValuesKey]);
|
||||||
|
|
||||||
// 옵션 새로고침
|
// 옵션 새로고침
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ export interface CascadingRelationUpdateInput extends Partial<CascadingRelationC
|
||||||
export interface CascadingOption {
|
export interface CascadingOption {
|
||||||
value: string;
|
value: string;
|
||||||
label: 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 {
|
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;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("연쇄 옵션 조회 실패:", error);
|
console.error("연쇄 옵션 조회 실패:", error);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -170,6 +170,12 @@ export const menuApi = {
|
||||||
screenNameConfig?: {
|
screenNameConfig?: {
|
||||||
removeText?: string;
|
removeText?: string;
|
||||||
addPrefix?: string;
|
addPrefix?: string;
|
||||||
|
},
|
||||||
|
additionalCopyOptions?: {
|
||||||
|
copyCodeCategory?: boolean;
|
||||||
|
copyNumberingRules?: boolean;
|
||||||
|
copyCategoryMapping?: boolean;
|
||||||
|
copyTableTypeColumns?: boolean;
|
||||||
}
|
}
|
||||||
): Promise<ApiResponse<MenuCopyResult>> => {
|
): Promise<ApiResponse<MenuCopyResult>> => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -177,7 +183,8 @@ export const menuApi = {
|
||||||
`/admin/menus/${menuObjid}/copy`,
|
`/admin/menus/${menuObjid}/copy`,
|
||||||
{
|
{
|
||||||
targetCompanyCode,
|
targetCompanyCode,
|
||||||
screenNameConfig
|
screenNameConfig,
|
||||||
|
additionalCopyOptions
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|
@ -199,6 +206,11 @@ export interface MenuCopyResult {
|
||||||
copiedMenus: number;
|
copiedMenus: number;
|
||||||
copiedScreens: number;
|
copiedScreens: number;
|
||||||
copiedFlows: number;
|
copiedFlows: number;
|
||||||
|
copiedCodeCategories?: number;
|
||||||
|
copiedCodes?: number;
|
||||||
|
copiedNumberingRules?: number;
|
||||||
|
copiedCategoryMappings?: number;
|
||||||
|
copiedTableTypeColumns?: number;
|
||||||
menuIdMap: Record<number, number>;
|
menuIdMap: Record<number, number>;
|
||||||
screenIdMap: Record<number, number>;
|
screenIdMap: Record<number, number>;
|
||||||
flowIdMap: Record<number, number>;
|
flowIdMap: Record<number, number>;
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
|
|
||||||
// 변환된 값 캐시 (중복 변환 방지)
|
// 변환된 값 캐시 (중복 변환 방지)
|
||||||
const convertedCache = useRef(new Map<string, string>());
|
const convertedCache = useRef(new Map<string, string>());
|
||||||
|
|
||||||
|
// 초기화 완료 플래그 (무한 루프 방지)
|
||||||
|
const initialLoadDone = useRef(false);
|
||||||
|
|
||||||
// 공통 코드 카테고리 추출 (메모이제이션)
|
// 공통 코드 카테고리 추출 (메모이제이션)
|
||||||
const codeCategories = useMemo(() => {
|
const codeCategories = useMemo(() => {
|
||||||
|
|
@ -293,24 +296,40 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
[codeCategories, batchLoadCodes, updateMetrics],
|
[codeCategories, batchLoadCodes, updateMetrics],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 초기화 시 공통 코드 프리로딩
|
// 초기화 시 공통 코드 프리로딩 (한 번만 실행)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 이미 초기화되었으면 스킵 (무한 루프 방지)
|
||||||
|
if (initialLoadDone.current) return;
|
||||||
|
initialLoadDone.current = true;
|
||||||
|
|
||||||
preloadCommonCodesOnMount();
|
preloadCommonCodesOnMount();
|
||||||
}, [preloadCommonCodesOnMount]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 컬럼 메타 변경 시 필요한 코드 추가 로딩
|
// 컬럼 메타 변경 시 필요한 코드 추가 로딩
|
||||||
|
// 이미 로딩 중이면 스킵하여 무한 루프 방지
|
||||||
|
const loadedCategoriesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 이미 최적화 중이거나 초기화 전이면 스킵
|
||||||
|
if (isOptimizing) return;
|
||||||
|
|
||||||
if (codeCategories.length > 0) {
|
if (codeCategories.length > 0) {
|
||||||
const unloadedCategories = codeCategories.filter((category) => {
|
const unloadedCategories = codeCategories.filter((category) => {
|
||||||
|
// 이미 로드 요청을 보낸 카테고리는 스킵
|
||||||
|
if (loadedCategoriesRef.current.has(category)) return false;
|
||||||
return codeCache.getCodeSync(category) === null;
|
return codeCache.getCodeSync(category) === null;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (unloadedCategories.length > 0) {
|
if (unloadedCategories.length > 0) {
|
||||||
|
// 로딩 요청 카테고리 기록
|
||||||
|
unloadedCategories.forEach(cat => loadedCategoriesRef.current.add(cat));
|
||||||
console.log(`🔄 새로운 코드 카테고리 감지, 추가 로딩: ${unloadedCategories.join(", ")}`);
|
console.log(`🔄 새로운 코드 카테고리 감지, 추가 로딩: ${unloadedCategories.join(", ")}`);
|
||||||
batchLoadCodes(unloadedCategories);
|
batchLoadCodes(unloadedCategories);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [codeCategories, batchLoadCodes]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [codeCategories.join(",")]); // 배열 내용 기반 의존성
|
||||||
|
|
||||||
// 주기적으로 메트릭 업데이트
|
// 주기적으로 메트릭 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
// originalData를 사용 (최초 전달된 값, formData는 계속 변경되므로 사용하면 안됨)
|
||||||
_initialData: originalData || formData,
|
_initialData: originalData || formData,
|
||||||
_originalData: originalData,
|
_originalData: originalData,
|
||||||
|
// 🆕 탭 관련 정보 전달 (탭 내부의 테이블 컴포넌트에서 사용)
|
||||||
|
parentTabId: props.parentTabId,
|
||||||
|
parentTabsComponentId: props.parentTabsComponentId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 렌더러가 클래스인지 함수인지 확인
|
// 렌더러가 클래스인지 함수인지 확인
|
||||||
|
|
|
||||||
|
|
@ -156,22 +156,48 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 🆕 연쇄 드롭다운 설정 확인
|
// 🆕 연쇄 드롭다운 설정 확인
|
||||||
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
const cascadingRelationCode = config?.cascadingRelationCode || componentConfig?.cascadingRelationCode;
|
||||||
|
// 🆕 카테고리 값 연쇄관계 설정
|
||||||
|
const categoryRelationCode = config?.categoryRelationCode || componentConfig?.categoryRelationCode;
|
||||||
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
const cascadingRole = config?.cascadingRole || componentConfig?.cascadingRole || "child";
|
||||||
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
const cascadingParentField = config?.cascadingParentField || componentConfig?.cascadingParentField;
|
||||||
// 자식 역할일 때만 부모 값 필요
|
|
||||||
const parentValue = cascadingRole === "child" && cascadingParentField && formData
|
// 🆕 자식 역할일 때 부모 값 추출 (단일 또는 다중)
|
||||||
|
const rawParentValue = cascadingRole === "child" && cascadingParentField && formData
|
||||||
? formData[cascadingParentField]
|
? formData[cascadingParentField]
|
||||||
: undefined;
|
: 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 {
|
const {
|
||||||
options: cascadingOptions,
|
options: cascadingOptions,
|
||||||
loading: isLoadingCascading,
|
loading: isLoadingCascading,
|
||||||
} = useCascadingDropdown({
|
} = useCascadingDropdown({
|
||||||
relationCode: cascadingRelationCode,
|
relationCode: cascadingRelationCode,
|
||||||
|
categoryRelationCode: categoryRelationCode, // 🆕 카테고리 값 연쇄관계 지원
|
||||||
role: cascadingRole, // 부모/자식 역할 전달
|
role: cascadingRole, // 부모/자식 역할 전달
|
||||||
parentValue: parentValue,
|
parentValues: parentValues, // 다중 부모값
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🆕 카테고리 값 연쇄관계가 활성화되었는지 확인
|
||||||
|
const hasCategoryRelation = !!categoryRelationCode;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (webType === "category" && component.tableName && component.columnName) {
|
if (webType === "category" && component.tableName && component.columnName) {
|
||||||
|
|
@ -324,6 +350,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 선택된 값에 따른 라벨 업데이트
|
// 선택된 값에 따른 라벨 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllOptionsForLabel = () => {
|
const getAllOptionsForLabel = () => {
|
||||||
|
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
||||||
|
if (categoryRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
if (cascadingRelationCode) {
|
if (cascadingRelationCode) {
|
||||||
return cascadingOptions;
|
return cascadingOptions;
|
||||||
|
|
@ -353,7 +383,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
if (newLabel !== selectedLabel) {
|
if (newLabel !== selectedLabel) {
|
||||||
setSelectedLabel(newLabel);
|
setSelectedLabel(newLabel);
|
||||||
}
|
}
|
||||||
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode]);
|
}, [selectedValue, codeOptions, config.options, cascadingOptions, cascadingRelationCode, categoryRelationCode]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
// 클릭 이벤트 핸들러 (React Query로 간소화)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
|
@ -404,6 +434,10 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
|
// 🆕 카테고리 값 연쇄관계가 설정된 경우 연쇄 옵션 사용
|
||||||
|
if (categoryRelationCode) {
|
||||||
|
return cascadingOptions;
|
||||||
|
}
|
||||||
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
// 🆕 연쇄 드롭다운이 설정된 경우 연쇄 옵션만 사용
|
||||||
if (cascadingRelationCode) {
|
if (cascadingRelationCode) {
|
||||||
return cascadingOptions;
|
return cascadingOptions;
|
||||||
|
|
@ -776,50 +810,121 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
{(isLoadingCodes || isLoadingCategories) ? (
|
{(isLoadingCodes || isLoadingCategories) ? (
|
||||||
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
<div className="bg-white px-3 py-2 text-gray-900">로딩 중...</div>
|
||||||
) : allOptions.length > 0 ? (
|
) : allOptions.length > 0 ? (
|
||||||
allOptions.map((option, index) => {
|
(() => {
|
||||||
const isOptionSelected = selectedValues.includes(option.value);
|
// 부모별 그룹핑 (카테고리 연쇄관계인 경우)
|
||||||
return (
|
const hasParentInfo = allOptions.some((opt: any) => opt.parent_label);
|
||||||
<div
|
|
||||||
key={`${option.value}-${index}`}
|
if (hasParentInfo) {
|
||||||
className={cn(
|
// 부모별로 그룹핑
|
||||||
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
const groupedOptions: Record<string, { parentLabel: string; options: typeof allOptions }> = {};
|
||||||
isOptionSelected && "bg-blue-50 font-medium"
|
allOptions.forEach((opt: any) => {
|
||||||
)}
|
const parentKey = opt.parent_value || "기타";
|
||||||
onClick={() => {
|
const parentLabel = opt.parent_label || "기타";
|
||||||
const newVals = isOptionSelected
|
if (!groupedOptions[parentKey]) {
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
groupedOptions[parentKey] = { parentLabel, options: [] };
|
||||||
: [...selectedValues, option.value];
|
}
|
||||||
setSelectedValues(newVals);
|
groupedOptions[parentKey].options.push(opt);
|
||||||
const newValue = newVals.join(",");
|
});
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
|
||||||
onFormDataChange(component.columnName, newValue);
|
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">
|
||||||
<div className="flex items-center gap-2">
|
{group.parentLabel}
|
||||||
<input
|
</div>
|
||||||
type="checkbox"
|
{/* 그룹 옵션들 */}
|
||||||
checked={isOptionSelected}
|
{group.options.map((option, index) => {
|
||||||
value={option.value}
|
const isOptionSelected = selectedValues.includes(option.value);
|
||||||
onChange={(e) => {
|
return (
|
||||||
// 체크박스 직접 클릭 시에도 올바른 값으로 처리
|
<div
|
||||||
e.stopPropagation();
|
key={`${option.value}-${index}`}
|
||||||
const newVals = isOptionSelected
|
className={cn(
|
||||||
? selectedValues.filter((v) => v !== option.value)
|
"cursor-pointer px-3 py-2 text-gray-900 hover:bg-gray-100",
|
||||||
: [...selectedValues, option.value];
|
isOptionSelected && "bg-blue-50 font-medium"
|
||||||
setSelectedValues(newVals);
|
)}
|
||||||
const newValue = newVals.join(",");
|
onClick={() => {
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
const newVals = isOptionSelected
|
||||||
onFormDataChange(component.columnName, newValue);
|
? selectedValues.filter((v) => v !== option.value)
|
||||||
}
|
: [...selectedValues, option.value];
|
||||||
}}
|
setSelectedValues(newVals);
|
||||||
className="h-4 w-4 pointer-events-auto"
|
const newValue = newVals.join(",");
|
||||||
/>
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
<span>{option.label || option.value}</span>
|
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>
|
||||||
</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>
|
<div className="bg-white px-3 py-2 text-gray-900">옵션이 없습니다</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { Link2, ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { SelectBasicConfig } from "./types";
|
import { SelectBasicConfig } from "./types";
|
||||||
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
import { cascadingRelationApi, CascadingRelation } from "@/lib/api/cascadingRelation";
|
||||||
|
import { categoryValueCascadingApi, CategoryValueCascadingGroup } from "@/lib/api/categoryValueCascading";
|
||||||
|
|
||||||
export interface SelectBasicConfigPanelProps {
|
export interface SelectBasicConfigPanelProps {
|
||||||
config: SelectBasicConfig;
|
config: SelectBasicConfig;
|
||||||
|
|
@ -35,6 +36,11 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
const [cascadingEnabled, setCascadingEnabled] = useState(!!config.cascadingRelationCode);
|
||||||
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
const [relationList, setRelationList] = useState<CascadingRelation[]>([]);
|
||||||
const [loadingRelations, setLoadingRelations] = useState(false);
|
const [loadingRelations, setLoadingRelations] = useState(false);
|
||||||
|
|
||||||
|
// 🆕 카테고리 값 연쇄관계 상태
|
||||||
|
const [categoryRelationEnabled, setCategoryRelationEnabled] = useState(!!(config as any).categoryRelationCode);
|
||||||
|
const [categoryRelationList, setCategoryRelationList] = useState<CategoryValueCascadingGroup[]>([]);
|
||||||
|
const [loadingCategoryRelations, setLoadingCategoryRelations] = useState(false);
|
||||||
|
|
||||||
// 연쇄 관계 목록 로드
|
// 연쇄 관계 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -43,10 +49,18 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
}
|
}
|
||||||
}, [cascadingEnabled]);
|
}, [cascadingEnabled]);
|
||||||
|
|
||||||
|
// 🆕 카테고리 값 연쇄관계 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (categoryRelationEnabled && categoryRelationList.length === 0) {
|
||||||
|
loadCategoryRelationList();
|
||||||
|
}
|
||||||
|
}, [categoryRelationEnabled]);
|
||||||
|
|
||||||
// config 변경 시 상태 동기화
|
// config 변경 시 상태 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCascadingEnabled(!!config.cascadingRelationCode);
|
setCascadingEnabled(!!config.cascadingRelationCode);
|
||||||
}, [config.cascadingRelationCode]);
|
setCategoryRelationEnabled(!!(config as any).categoryRelationCode);
|
||||||
|
}, [config.cascadingRelationCode, (config as any).categoryRelationCode]);
|
||||||
|
|
||||||
const loadRelationList = async () => {
|
const loadRelationList = async () => {
|
||||||
setLoadingRelations(true);
|
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) => {
|
const handleChange = (key: keyof SelectBasicConfig, value: any) => {
|
||||||
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
// 기존 config와 병합하여 전체 객체 전달 (다른 속성 보호)
|
||||||
const newConfig = { ...config, [key]: value };
|
const newConfig = { ...config, [key]: value };
|
||||||
|
|
@ -82,6 +111,33 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
onChange(newConfig);
|
onChange(newConfig);
|
||||||
} else {
|
} else {
|
||||||
loadRelationList();
|
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" && (
|
{config.cascadingRelationCode && config.cascadingRole === "child" && (() => {
|
||||||
<div className="space-y-2">
|
// 선택된 관계에서 부모 값 컬럼 가져오기
|
||||||
<Label className="text-xs">부모 필드명</Label>
|
const expectedParentColumn = selectedRelation?.parent_value_column;
|
||||||
{(() => {
|
|
||||||
const parentComp = findParentComponent(config.cascadingRelationCode);
|
// 부모 역할에 맞는 컴포넌트만 필터링
|
||||||
const isAutoDetected = parentComp && config.cascadingParentField === parentComp.columnName;
|
const parentFieldCandidates = allComponents.filter((comp) => {
|
||||||
|
// 현재 컴포넌트 제외
|
||||||
return (
|
if (currentComponent && comp.id === currentComponent.id) return false;
|
||||||
<>
|
// 관계에서 지정한 부모 컬럼명과 일치하는 컴포넌트만
|
||||||
<div className="flex gap-2 items-center">
|
if (expectedParentColumn && comp.columnName !== expectedParentColumn) return false;
|
||||||
<Input
|
// columnName이 있어야 함
|
||||||
value={config.cascadingParentField || ""}
|
return !!comp.columnName;
|
||||||
onChange={(e) => handleChange("cascadingParentField", e.target.value || undefined)}
|
});
|
||||||
placeholder="예: warehouse_code"
|
|
||||||
className="text-xs flex-1"
|
return (
|
||||||
/>
|
<div className="space-y-2">
|
||||||
{parentComp && !isAutoDetected && (
|
<Label className="text-xs">부모 필드 선택</Label>
|
||||||
<Button
|
{expectedParentColumn && (
|
||||||
type="button"
|
<p className="text-muted-foreground text-xs">
|
||||||
size="sm"
|
관계에서 지정된 부모 컬럼: <strong>{expectedParentColumn}</strong>
|
||||||
variant="outline"
|
</p>
|
||||||
className="text-xs shrink-0"
|
)}
|
||||||
onClick={() => handleChange("cascadingParentField", parentComp.columnName)}
|
<Select
|
||||||
>
|
value={config.cascadingParentField || ""}
|
||||||
자동감지
|
onValueChange={(value) => handleChange("cascadingParentField", value || undefined)}
|
||||||
</Button>
|
>
|
||||||
)}
|
<SelectTrigger className="text-xs">
|
||||||
</div>
|
<SelectValue placeholder="부모 필드 선택" />
|
||||||
{isAutoDetected ? (
|
</SelectTrigger>
|
||||||
<p className="text-xs text-green-600">
|
<SelectContent>
|
||||||
자동 감지됨: {parentComp.label || parentComp.columnName}
|
{parentFieldCandidates.map((comp) => (
|
||||||
</p>
|
<SelectItem key={comp.id} value={comp.columnName}>
|
||||||
) : parentComp ? (
|
{comp.label || comp.columnName} ({comp.columnName})
|
||||||
<p className="text-xs text-amber-600">
|
</SelectItem>
|
||||||
감지된 부모 필드: {parentComp.columnName} ({parentComp.label || "라벨 없음"})
|
))}
|
||||||
</p>
|
{parentFieldCandidates.length === 0 && (
|
||||||
) : (
|
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||||
<p className="text-muted-foreground text-xs">
|
{expectedParentColumn
|
||||||
같은 관계의 부모 역할 필드가 없습니다. 수동으로 입력하세요.
|
? `'${expectedParentColumn}' 컬럼을 가진 컴포넌트가 화면에 없습니다`
|
||||||
</p>
|
: "선택 가능한 부모 필드가 없습니다"}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</SelectContent>
|
||||||
);
|
</Select>
|
||||||
})()}
|
<p className="text-muted-foreground text-xs">
|
||||||
</div>
|
상위 값을 제공할 필드를 선택하세요.
|
||||||
)}
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 선택된 관계 정보 표시 */}
|
{/* 선택된 관계 정보 표시 */}
|
||||||
{selectedRelation && config.cascadingRole && (
|
{selectedRelation && config.cascadingRole && (
|
||||||
|
|
@ -374,6 +434,152 @@ export const SelectBasicConfigPanel: React.FC<SelectBasicConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -687,7 +687,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false);
|
||||||
const [showGridLines, setShowGridLines] = useState(true);
|
const [showGridLines, setShowGridLines] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
|
||||||
const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
|
// 체크박스 컬럼은 항상 기본 틀고정
|
||||||
|
const [frozenColumns, setFrozenColumns] = useState<string[]>(
|
||||||
|
(tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []
|
||||||
|
);
|
||||||
|
const [frozenColumnCount, setFrozenColumnCount] = useState<number>(0);
|
||||||
|
|
||||||
// 🆕 Search Panel (통합 검색) 관련 상태
|
// 🆕 Search Panel (통합 검색) 관련 상태
|
||||||
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
const [globalSearchTerm, setGlobalSearchTerm] = useState("");
|
||||||
|
|
@ -1022,6 +1026,19 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
getColumnUniqueValues, // 고유 값 조회 함수 등록
|
||||||
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
onGroupSumChange: setGroupSumConfig, // 그룹별 합산 설정
|
||||||
|
// 틀고정 컬럼 관련
|
||||||
|
frozenColumnCount, // 현재 틀고정 컬럼 수
|
||||||
|
onFrozenColumnCountChange: (count: number) => {
|
||||||
|
setFrozenColumnCount(count);
|
||||||
|
// 체크박스 컬럼은 항상 틀고정에 포함
|
||||||
|
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [];
|
||||||
|
// 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정
|
||||||
|
const visibleCols = columnsToRegister
|
||||||
|
.filter((col) => col.visible !== false)
|
||||||
|
.map((col) => col.columnName || col.field);
|
||||||
|
const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)];
|
||||||
|
setFrozenColumns(newFrozenColumns);
|
||||||
|
},
|
||||||
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
// 탭 관련 정보 (탭 내부의 테이블인 경우)
|
||||||
parentTabId,
|
parentTabId,
|
||||||
parentTabsComponentId,
|
parentTabsComponentId,
|
||||||
|
|
@ -1033,6 +1050,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return () => {
|
return () => {
|
||||||
unregisterTable(tableId);
|
unregisterTable(tableId);
|
||||||
};
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
tableId,
|
tableId,
|
||||||
tableConfig.selectedTable,
|
tableConfig.selectedTable,
|
||||||
|
|
@ -1044,7 +1062,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
data, // 데이터 자체가 변경되면 재등록 (고유 값 조회용)
|
||||||
totalItems, // 전체 항목 수가 변경되면 재등록
|
totalItems, // 전체 항목 수가 변경되면 재등록
|
||||||
registerTable,
|
registerTable,
|
||||||
unregisterTable,
|
// unregisterTable은 의존성에서 제외 - 무한 루프 방지
|
||||||
|
// unregisterTable 함수는 의존성이 없어 안정적임
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
// 🎯 초기 로드 시 localStorage에서 정렬 상태 불러오기
|
||||||
|
|
@ -2877,6 +2896,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
sortDirection,
|
sortDirection,
|
||||||
groupByColumns,
|
groupByColumns,
|
||||||
frozenColumns,
|
frozenColumns,
|
||||||
|
frozenColumnCount, // 틀고정 컬럼 수 저장
|
||||||
showGridLines,
|
showGridLines,
|
||||||
headerFilters: Object.fromEntries(
|
headerFilters: Object.fromEntries(
|
||||||
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
Object.entries(headerFilters).map(([key, set]) => [key, Array.from(set as Set<string>)]),
|
||||||
|
|
@ -2898,6 +2918,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
sortDirection,
|
sortDirection,
|
||||||
groupByColumns,
|
groupByColumns,
|
||||||
frozenColumns,
|
frozenColumns,
|
||||||
|
frozenColumnCount,
|
||||||
showGridLines,
|
showGridLines,
|
||||||
headerFilters,
|
headerFilters,
|
||||||
localPageSize,
|
localPageSize,
|
||||||
|
|
@ -2918,7 +2939,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
|
if (state.sortColumn !== undefined) setSortColumn(state.sortColumn);
|
||||||
if (state.sortDirection) setSortDirection(state.sortDirection);
|
if (state.sortDirection) setSortDirection(state.sortDirection);
|
||||||
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
if (state.groupByColumns) setGroupByColumns(state.groupByColumns);
|
||||||
if (state.frozenColumns) setFrozenColumns(state.frozenColumns);
|
if (state.frozenColumns) {
|
||||||
|
// 체크박스 컬럼이 항상 포함되도록 보장
|
||||||
|
const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null;
|
||||||
|
const restoredFrozenColumns = checkboxColumn && !state.frozenColumns.includes(checkboxColumn)
|
||||||
|
? [checkboxColumn, ...state.frozenColumns]
|
||||||
|
: state.frozenColumns;
|
||||||
|
setFrozenColumns(restoredFrozenColumns);
|
||||||
|
}
|
||||||
|
if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원
|
||||||
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
if (state.showGridLines !== undefined) setShowGridLines(state.showGridLines);
|
||||||
if (state.headerFilters) {
|
if (state.headerFilters) {
|
||||||
const filters: Record<string, Set<string>> = {};
|
const filters: Record<string, Set<string>> = {};
|
||||||
|
|
@ -5588,7 +5617,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (isFrozen && frozenIndex > 0) {
|
if (isFrozen && frozenIndex > 0) {
|
||||||
for (let i = 0; i < frozenIndex; i++) {
|
for (let i = 0; i < frozenIndex; i++) {
|
||||||
const frozenCol = frozenColumns[i];
|
const frozenCol = frozenColumns[i];
|
||||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
// 체크박스 컬럼은 48px 고정
|
||||||
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||||
leftPosition += frozenColWidth;
|
leftPosition += frozenColWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5607,7 +5637,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
column.sortable !== false &&
|
column.sortable !== false &&
|
||||||
column.columnName !== "__checkbox__" &&
|
column.columnName !== "__checkbox__" &&
|
||||||
"hover:bg-muted/70 cursor-pointer transition-colors",
|
"hover:bg-muted/70 cursor-pointer transition-colors",
|
||||||
isFrozen && "sticky z-60 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]",
|
||||||
// 🆕 Column Reordering 스타일
|
// 🆕 Column Reordering 스타일
|
||||||
isColumnDragEnabled &&
|
isColumnDragEnabled &&
|
||||||
column.columnName !== "__checkbox__" &&
|
column.columnName !== "__checkbox__" &&
|
||||||
|
|
@ -5899,7 +5929,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (isFrozen && frozenIndex > 0) {
|
if (isFrozen && frozenIndex > 0) {
|
||||||
for (let i = 0; i < frozenIndex; i++) {
|
for (let i = 0; i < frozenIndex; i++) {
|
||||||
const frozenCol = frozenColumns[i];
|
const frozenCol = frozenColumns[i];
|
||||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
// 체크박스 컬럼은 48px 고정
|
||||||
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||||
leftPosition += frozenColWidth;
|
leftPosition += frozenColWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5912,7 +5943,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "px-0 py-1"
|
? "px-0 py-1"
|
||||||
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
: "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign:
|
textAlign:
|
||||||
|
|
@ -5927,7 +5958,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: `${100 / visibleColumns.length}%`,
|
: `${100 / visibleColumns.length}%`,
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
...(isFrozen && {
|
||||||
|
left: `${leftPosition}px`,
|
||||||
|
backgroundColor: "hsl(var(--background))",
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{column.columnName === "__checkbox__"
|
{column.columnName === "__checkbox__"
|
||||||
|
|
@ -6059,7 +6093,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (isFrozen && frozenIndex > 0) {
|
if (isFrozen && frozenIndex > 0) {
|
||||||
for (let i = 0; i < frozenIndex; i++) {
|
for (let i = 0; i < frozenIndex; i++) {
|
||||||
const frozenCol = frozenColumns[i];
|
const frozenCol = frozenColumns[i];
|
||||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
// 체크박스 컬럼은 48px 고정
|
||||||
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||||
leftPosition += frozenColWidth;
|
leftPosition += frozenColWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6072,7 +6107,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
|
"text-foreground overflow-hidden text-xs font-normal text-ellipsis whitespace-nowrap sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5",
|
||||||
isFrozen && "bg-background sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||||
// 🆕 포커스된 셀 스타일
|
// 🆕 포커스된 셀 스타일
|
||||||
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset",
|
||||||
// 🆕 편집 중인 셀 스타일
|
// 🆕 편집 중인 셀 스타일
|
||||||
|
|
@ -6099,7 +6134,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
column.columnName === "__checkbox__" ? "48px" : `${100 / visibleColumns.length}%`,
|
||||||
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
minWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined,
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
...(isFrozen && {
|
||||||
|
left: `${leftPosition}px`,
|
||||||
|
backgroundColor: "hsl(var(--background))",
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
onClick={(e) => handleCellClick(index, colIndex, e)}
|
onClick={(e) => handleCellClick(index, colIndex, e)}
|
||||||
onDoubleClick={() =>
|
onDoubleClick={() =>
|
||||||
|
|
@ -6220,7 +6258,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (isFrozen && frozenIndex > 0) {
|
if (isFrozen && frozenIndex > 0) {
|
||||||
for (let i = 0; i < frozenIndex; i++) {
|
for (let i = 0; i < frozenIndex; i++) {
|
||||||
const frozenCol = frozenColumns[i];
|
const frozenCol = frozenColumns[i];
|
||||||
const frozenColWidth = columnWidths[frozenCol] || 150;
|
// 체크박스 컬럼은 48px 고정
|
||||||
|
const frozenColWidth = frozenCol === "__checkbox__" ? 48 : (columnWidths[frozenCol] || 150);
|
||||||
leftPosition += frozenColWidth;
|
leftPosition += frozenColWidth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -6235,7 +6274,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-foreground text-xs font-semibold sm:text-sm",
|
"text-foreground text-xs font-semibold sm:text-sm",
|
||||||
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
|
column.columnName === "__checkbox__" ? "px-0 py-2" : "px-2 py-2 sm:px-4",
|
||||||
isFrozen && "bg-muted/80 sticky z-10 shadow-[2px_0_4px_rgba(0,0,0,0.05)]",
|
isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
textAlign: isNumeric ? "right" : column.align || "left",
|
textAlign: isNumeric ? "right" : column.align || "left",
|
||||||
|
|
@ -6245,7 +6284,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: columnWidth
|
: columnWidth
|
||||||
? `${columnWidth}px`
|
? `${columnWidth}px`
|
||||||
: undefined,
|
: undefined,
|
||||||
...(isFrozen && { left: `${leftPosition}px` }),
|
...(isFrozen && {
|
||||||
|
left: `${leftPosition}px`,
|
||||||
|
backgroundColor: "hsl(var(--muted) / 0.8)",
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{summary ? (
|
{summary ? (
|
||||||
|
|
|
||||||
|
|
@ -138,33 +138,84 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
|
|
||||||
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
// currentTable은 tableList(필터링된 목록)에서 가져와야 함
|
||||||
const currentTable = useMemo(() => {
|
const currentTable = useMemo(() => {
|
||||||
|
console.log("🔍 [TableSearchWidget] currentTable 계산:", {
|
||||||
|
selectedTableId,
|
||||||
|
tableListLength: tableList.length,
|
||||||
|
tableList: tableList.map(t => ({ id: t.tableId, name: t.tableName, parentTabId: t.parentTabId }))
|
||||||
|
});
|
||||||
|
|
||||||
if (!selectedTableId) return undefined;
|
if (!selectedTableId) return undefined;
|
||||||
|
|
||||||
// 먼저 tableList(필터링된 목록)에서 찾기
|
// 먼저 tableList(필터링된 목록)에서 찾기
|
||||||
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
|
const tableFromList = tableList.find(t => t.tableId === selectedTableId);
|
||||||
if (tableFromList) {
|
if (tableFromList) {
|
||||||
|
console.log("✅ [TableSearchWidget] 테이블 찾음 (tableList):", tableFromList.tableName);
|
||||||
return tableFromList;
|
return tableFromList;
|
||||||
}
|
}
|
||||||
|
|
||||||
// tableList에 없으면 전체에서 찾기 (폴백)
|
// tableList에 없으면 전체에서 찾기 (폴백)
|
||||||
return getTable(selectedTableId);
|
const tableFromAll = getTable(selectedTableId);
|
||||||
|
console.log("🔄 [TableSearchWidget] 테이블 찾음 (전체):", tableFromAll?.tableName);
|
||||||
|
return tableFromAll;
|
||||||
}, [selectedTableId, tableList, getTable]);
|
}, [selectedTableId, tableList, getTable]);
|
||||||
|
|
||||||
|
// 🆕 활성 탭 ID 문자열 (변경 감지용)
|
||||||
|
const activeTabIdsStr = useMemo(() => activeTabIds.join(","), [activeTabIds]);
|
||||||
|
|
||||||
|
// 🆕 이전 활성 탭 ID 추적 (탭 전환 감지용)
|
||||||
|
const prevActiveTabIdsRef = useRef<string>(activeTabIdsStr);
|
||||||
|
|
||||||
// 대상 패널의 첫 번째 테이블 자동 선택
|
// 대상 패널의 첫 번째 테이블 자동 선택
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSelectFirstTable || tableList.length === 0) {
|
if (!autoSelectFirstTable || tableList.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 탭 전환 감지: 활성 탭이 변경되었는지 확인
|
||||||
|
const tabChanged = prevActiveTabIdsRef.current !== activeTabIdsStr;
|
||||||
|
if (tabChanged) {
|
||||||
|
console.log("🔄 [TableSearchWidget] 탭 전환 감지:", {
|
||||||
|
이전탭: prevActiveTabIdsRef.current,
|
||||||
|
현재탭: activeTabIdsStr,
|
||||||
|
가용테이블: tableList.map(t => ({ id: t.tableId, tableName: t.tableName, parentTabId: t.parentTabId })),
|
||||||
|
현재선택테이블: selectedTableId
|
||||||
|
});
|
||||||
|
prevActiveTabIdsRef.current = activeTabIdsStr;
|
||||||
|
|
||||||
|
// 🆕 탭 전환 시: 해당 탭에 속한 테이블 중 첫 번째 강제 선택
|
||||||
|
const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||||
|
const targetTable = activeTabTable || tableList[0];
|
||||||
|
|
||||||
|
if (targetTable) {
|
||||||
|
console.log("✅ [TableSearchWidget] 탭 전환으로 테이블 강제 선택:", {
|
||||||
|
테이블ID: targetTable.tableId,
|
||||||
|
테이블명: targetTable.tableName,
|
||||||
|
탭ID: targetTable.parentTabId,
|
||||||
|
이전테이블: selectedTableId
|
||||||
|
});
|
||||||
|
setSelectedTableId(targetTable.tableId);
|
||||||
|
}
|
||||||
|
return; // 탭 전환 시에는 여기서 종료
|
||||||
|
}
|
||||||
|
|
||||||
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
// 현재 선택된 테이블이 대상 패널에 있는지 확인
|
||||||
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
|
const isCurrentTableInTarget = selectedTableId && tableList.some(t => t.tableId === selectedTableId);
|
||||||
|
|
||||||
// 현재 선택된 테이블이 대상 패널에 없으면 대상 패널의 첫 번째 테이블 선택
|
// 현재 선택된 테이블이 대상 패널에 없으면 첫 번째 테이블 선택
|
||||||
if (!selectedTableId || !isCurrentTableInTarget) {
|
if (!selectedTableId || !isCurrentTableInTarget) {
|
||||||
const targetTable = tableList[0];
|
const activeTabTable = tableList.find(t => t.parentTabId && activeTabIds.includes(t.parentTabId));
|
||||||
setSelectedTableId(targetTable.tableId);
|
const targetTable = activeTabTable || tableList[0];
|
||||||
|
|
||||||
|
if (targetTable && targetTable.tableId !== selectedTableId) {
|
||||||
|
console.log("✅ [TableSearchWidget] 테이블 자동 선택 (초기):", {
|
||||||
|
테이블ID: targetTable.tableId,
|
||||||
|
테이블명: targetTable.tableName,
|
||||||
|
탭ID: targetTable.parentTabId
|
||||||
|
});
|
||||||
|
setSelectedTableId(targetTable.tableId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition]);
|
}, [tableList, selectedTableId, autoSelectFirstTable, setSelectedTableId, targetPanelPosition, activeTabIdsStr, activeTabIds]);
|
||||||
|
|
||||||
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
|
// 현재 선택된 테이블의 탭 ID (탭별 필터 저장용)
|
||||||
const currentTableTabId = currentTable?.parentTabId;
|
const currentTableTabId = currentTable?.parentTabId;
|
||||||
|
|
@ -196,6 +247,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
|
|
||||||
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
// 현재 테이블의 저장된 필터 불러오기 (동적 모드) 또는 고정 필터 적용 (고정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
console.log("📋 [TableSearchWidget] 필터 설정 useEffect 실행:", {
|
||||||
|
currentTable: currentTable?.tableName,
|
||||||
|
currentTableTabId,
|
||||||
|
filterMode,
|
||||||
|
selectedTableId,
|
||||||
|
컬럼수: currentTable?.columns?.length
|
||||||
|
});
|
||||||
if (!currentTable?.tableName) return;
|
if (!currentTable?.tableName) return;
|
||||||
|
|
||||||
// 고정 모드: presetFilters를 activeFilters로 설정
|
// 고정 모드: presetFilters를 activeFilters로 설정
|
||||||
|
|
@ -229,12 +287,20 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 모드: 화면별 + 탭별로 독립적인 필터 설정 불러오기
|
// 동적 모드: 화면별로 독립적인 필터 설정 불러오기
|
||||||
|
// 참고: FilterPanel.tsx에서도 screenId만 사용하여 저장하므로 키가 일치해야 함
|
||||||
const filterConfigKey = screenId
|
const filterConfigKey = screenId
|
||||||
? `table_filters_${currentTable.tableName}_screen_${screenId}${currentTableTabId ? `_tab_${currentTableTabId}` : ''}`
|
? `table_filters_${currentTable.tableName}_screen_${screenId}`
|
||||||
: `table_filters_${currentTable.tableName}`;
|
: `table_filters_${currentTable.tableName}`;
|
||||||
const savedFilters = localStorage.getItem(filterConfigKey);
|
const savedFilters = localStorage.getItem(filterConfigKey);
|
||||||
|
|
||||||
|
console.log("🔑 [TableSearchWidget] 필터 설정 키 확인:", {
|
||||||
|
filterConfigKey,
|
||||||
|
savedFilters: savedFilters ? `${savedFilters.substring(0, 100)}...` : null,
|
||||||
|
screenId,
|
||||||
|
tableName: currentTable.tableName
|
||||||
|
});
|
||||||
|
|
||||||
if (savedFilters) {
|
if (savedFilters) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedFilters) as Array<{
|
const parsed = JSON.parse(savedFilters) as Array<{
|
||||||
|
|
@ -257,6 +323,13 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
width: f.width || 200,
|
width: f.width || 200,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log("📌 [TableSearchWidget] 필터 설정 로드:", {
|
||||||
|
filterConfigKey,
|
||||||
|
총필터수: parsed.length,
|
||||||
|
활성화필터수: activeFiltersList.length,
|
||||||
|
활성화필터: activeFiltersList.map(f => f.columnName)
|
||||||
|
});
|
||||||
|
|
||||||
setActiveFilters(activeFiltersList);
|
setActiveFilters(activeFiltersList);
|
||||||
|
|
||||||
// 탭별 저장된 필터 값 복원
|
// 탭별 저장된 필터 값 복원
|
||||||
|
|
@ -280,10 +353,19 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("저장된 필터 불러오기 실패:", error);
|
console.error("저장된 필터 불러오기 실패:", error);
|
||||||
|
// 파싱 에러 시 필터 초기화
|
||||||
|
setActiveFilters([]);
|
||||||
|
setFilterValues({});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 필터 설정이 없으면 초기화
|
// 필터 설정이 없으면 activeFilters와 filterValues 모두 초기화
|
||||||
|
console.log("⚠️ [TableSearchWidget] 저장된 필터 설정 없음 - 필터 초기화:", {
|
||||||
|
tableName: currentTable.tableName,
|
||||||
|
filterConfigKey
|
||||||
|
});
|
||||||
|
setActiveFilters([]);
|
||||||
setFilterValues({});
|
setFilterValues({});
|
||||||
|
setSelectOptions({});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
}, [currentTable?.tableName, filterMode, screenId, currentTableTabId, JSON.stringify(presetFilters)]);
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,10 @@ export interface TableRegistration {
|
||||||
onGroupChange: (groups: string[]) => void;
|
onGroupChange: (groups: string[]) => void;
|
||||||
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
|
||||||
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
|
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 그룹별 합산 설정 변경
|
||||||
|
onFrozenColumnCountChange?: (count: number) => void; // 틀고정 컬럼 수 변경
|
||||||
|
|
||||||
|
// 현재 설정 값 (읽기 전용)
|
||||||
|
frozenColumnCount?: number; // 현재 틀고정 컬럼 수
|
||||||
|
|
||||||
// 데이터 조회 함수 (선택 타입 필터용)
|
// 데이터 조회 함수 (선택 타입 필터용)
|
||||||
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue