Merge branch 'main' into lhj - resolve TableListComponent conflict

This commit is contained in:
leeheejin 2025-12-11 16:28:46 +09:00
commit c2a6dbea3b
15 changed files with 2834 additions and 1850 deletions

View File

@ -3,7 +3,8 @@
*
*/
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
@ -14,7 +15,10 @@ import logger from "../utils/logger";
/**
*
*/
export const getAutoFillGroups = async (req: Request, res: Response) => {
export const getAutoFillGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
@ -47,7 +51,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => {
const result = await query(sql, params);
logger.info("자동 입력 그룹 목록 조회", { count: result.length, companyCode });
logger.info("자동 입력 그룹 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
@ -66,7 +73,10 @@ export const getAutoFillGroups = async (req: Request, res: Response) => {
/**
* ( )
*/
export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
export const getAutoFillGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -98,7 +108,10 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
WHERE group_code = $1 AND company_code = $2
ORDER BY sort_order, mapping_id
`;
const mappingResult = await query(mappingSql, [groupCode, groupResult.company_code]);
const mappingResult = await query(mappingSql, [
groupCode,
groupResult.company_code,
]);
logger.info("자동 입력 그룹 상세 조회", { groupCode, companyCode });
@ -122,7 +135,9 @@ export const getAutoFillGroupDetail = async (req: Request, res: Response) => {
/**
*
*/
const generateAutoFillGroupCode = async (companyCode: string): Promise<string> => {
const generateAutoFillGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "AF";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_auto_fill_group WHERE company_code = $1`,
@ -136,7 +151,10 @@ const generateAutoFillGroupCode = async (companyCode: string): Promise<string> =
/**
*
*/
export const createAutoFillGroup = async (req: Request, res: Response) => {
export const createAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
@ -153,7 +171,8 @@ export const createAutoFillGroup = async (req: Request, res: Response) => {
if (!groupName || !masterTable || !masterValueColumn) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
message:
"필수 필드가 누락되었습니다. (groupName, masterTable, masterValueColumn)",
});
}
@ -224,7 +243,10 @@ export const createAutoFillGroup = async (req: Request, res: Response) => {
/**
*
*/
export const updateAutoFillGroup = async (req: Request, res: Response) => {
export const updateAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -333,7 +355,10 @@ export const updateAutoFillGroup = async (req: Request, res: Response) => {
/**
*
*/
export const deleteAutoFillGroup = async (req: Request, res: Response) => {
export const deleteAutoFillGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -382,7 +407,10 @@ export const deleteAutoFillGroup = async (req: Request, res: Response) => {
*
*
*/
export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
export const getAutoFillMasterOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -436,7 +464,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
const optionsResult = await query(optionsSql, optionsParams);
logger.info("자동 입력 마스터 옵션 조회", { groupCode, count: optionsResult.length });
logger.info("자동 입력 마스터 옵션 조회", {
groupCode,
count: optionsResult.length,
});
res.json({
success: true,
@ -456,7 +487,10 @@ export const getAutoFillMasterOptions = async (req: Request, res: Response) => {
*
*
*/
export const getAutoFillData = async (req: Request, res: Response) => {
export const getAutoFillData = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const { masterValue } = req.query;
@ -535,9 +569,10 @@ export const getAutoFillData = async (req: Request, res: Response) => {
for (const mapping of mappings) {
const sourceValue = dataResult?.[mapping.source_column];
const finalValue = sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
const finalValue =
sourceValue !== null && sourceValue !== undefined
? sourceValue
: mapping.default_value;
autoFillData[mapping.target_field] = finalValue;
mappingInfo.push({
@ -549,7 +584,11 @@ export const getAutoFillData = async (req: Request, res: Response) => {
});
}
logger.info("자동 입력 데이터 조회", { groupCode, masterValue, fieldCount: mappingInfo.length });
logger.info("자동 입력 데이터 조회", {
groupCode,
masterValue,
fieldCount: mappingInfo.length,
});
res.json({
success: true,
@ -565,4 +604,3 @@ export const getAutoFillData = async (req: Request, res: Response) => {
});
}
};

View File

@ -3,7 +3,8 @@
*
*/
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
@ -14,7 +15,10 @@ import logger from "../utils/logger";
/**
*
*/
export const getConditions = async (req: Request, res: Response) => {
export const getConditions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, relationCode, relationType } = req.query;
@ -54,7 +58,10 @@ export const getConditions = async (req: Request, res: Response) => {
const result = await query(sql, params);
logger.info("조건부 연쇄 규칙 목록 조회", { count: result.length, companyCode });
logger.info("조건부 연쇄 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
@ -62,7 +69,7 @@ export const getConditions = async (req: Request, res: Response) => {
});
} catch (error: any) {
console.error("조건부 연쇄 규칙 목록 조회 실패:", error);
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
logger.error("조건부 연쇄 규칙 목록 조회 실패", {
error: error.message,
stack: error.stack,
});
@ -77,7 +84,10 @@ export const getConditions = async (req: Request, res: Response) => {
/**
*
*/
export const getConditionDetail = async (req: Request, res: Response) => {
export const getConditionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -118,7 +128,10 @@ export const getConditionDetail = async (req: Request, res: Response) => {
/**
*
*/
export const createCondition = async (req: Request, res: Response) => {
export const createCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
@ -134,10 +147,18 @@ export const createCondition = async (req: Request, res: Response) => {
} = req.body;
// 필수 필드 검증
if (!relationCode || !conditionName || !conditionField || !conditionValue || !filterColumn || !filterValues) {
if (
!relationCode ||
!conditionName ||
!conditionField ||
!conditionValue ||
!filterColumn ||
!filterValues
) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
message:
"필수 필드가 누락되었습니다. (relationCode, conditionName, conditionField, conditionValue, filterColumn, filterValues)",
});
}
@ -164,7 +185,11 @@ export const createCondition = async (req: Request, res: Response) => {
companyCode,
]);
logger.info("조건부 연쇄 규칙 생성", { conditionId: result?.condition_id, relationCode, companyCode });
logger.info("조건부 연쇄 규칙 생성", {
conditionId: result?.condition_id,
relationCode,
companyCode,
});
res.status(201).json({
success: true,
@ -184,7 +209,10 @@ export const createCondition = async (req: Request, res: Response) => {
/**
*
*/
export const updateCondition = async (req: Request, res: Response) => {
export const updateCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -264,7 +292,10 @@ export const updateCondition = async (req: Request, res: Response) => {
/**
*
*/
export const deleteCondition = async (req: Request, res: Response) => {
export const deleteCondition = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { conditionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -312,7 +343,10 @@ export const deleteCondition = async (req: Request, res: Response) => {
*
*
*/
export const getFilteredOptions = async (req: Request, res: Response) => {
export const getFilteredOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { relationCode } = req.params;
const { conditionFieldValue, parentValue } = req.query;
@ -390,8 +424,12 @@ export const getFilteredOptions = async (req: Request, res: Response) => {
// 조건부 필터 적용
if (matchedCondition) {
const filterValues = matchedCondition.filter_values.split(",").map((v: string) => v.trim());
const placeholders = filterValues.map((_: any, i: number) => `$${optionsParamIndex + i}`).join(",");
const filterValues = matchedCondition.filter_values
.split(",")
.map((v: string) => v.trim());
const placeholders = filterValues
.map((_: any, i: number) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${matchedCondition.filter_column} IN (${placeholders})`;
optionsParams.push(...filterValues);
optionsParamIndex += filterValues.length;
@ -522,4 +560,3 @@ function evaluateCondition(
return false;
}
}

View File

@ -3,7 +3,8 @@
* > > /
*/
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
@ -14,7 +15,10 @@ import logger from "../utils/logger";
/**
*
*/
export const getHierarchyGroups = async (req: Request, res: Response) => {
export const getHierarchyGroups = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive, hierarchyType } = req.query;
@ -66,7 +70,10 @@ export const getHierarchyGroups = async (req: Request, res: Response) => {
/**
* ( )
*/
export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
export const getHierarchyGroupDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -124,7 +131,9 @@ export const getHierarchyGroupDetail = async (req: Request, res: Response) => {
/**
*
*/
const generateHierarchyGroupCode = async (companyCode: string): Promise<string> => {
const generateHierarchyGroupCode = async (
companyCode: string
): Promise<string> => {
const prefix = "HG";
const result = await queryOne(
`SELECT COUNT(*) as cnt FROM cascading_hierarchy_group WHERE company_code = $1`,
@ -138,7 +147,10 @@ const generateHierarchyGroupCode = async (companyCode: string): Promise<string>
/**
*
*/
export const createHierarchyGroup = async (req: Request, res: Response) => {
export const createHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
@ -280,7 +292,10 @@ export const createHierarchyGroup = async (req: Request, res: Response) => {
/**
*
*/
export const updateHierarchyGroup = async (req: Request, res: Response) => {
export const updateHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -364,7 +379,10 @@ export const updateHierarchyGroup = async (req: Request, res: Response) => {
/**
*
*/
export const deleteHierarchyGroup = async (req: Request, res: Response) => {
export const deleteHierarchyGroup = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -423,7 +441,7 @@ export const deleteHierarchyGroup = async (req: Request, res: Response) => {
/**
*
*/
export const addLevel = async (req: Request, res: Response) => {
export const addLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { groupCode } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -506,7 +524,7 @@ export const addLevel = async (req: Request, res: Response) => {
/**
*
*/
export const updateLevel = async (req: Request, res: Response) => {
export const updateLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -600,7 +618,7 @@ export const updateLevel = async (req: Request, res: Response) => {
/**
*
*/
export const deleteLevel = async (req: Request, res: Response) => {
export const deleteLevel = async (req: AuthenticatedRequest, res: Response) => {
try {
const { levelId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -647,7 +665,10 @@ export const deleteLevel = async (req: Request, res: Response) => {
/**
*
*/
export const getLevelOptions = async (req: Request, res: Response) => {
export const getLevelOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { groupCode, levelOrder } = req.params;
const { parentValue } = req.query;
@ -749,4 +770,3 @@ export const getLevelOptions = async (req: Request, res: Response) => {
});
}
};

View File

@ -3,7 +3,8 @@
*
*/
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { query, queryOne } from "../database/db";
import logger from "../utils/logger";
@ -14,7 +15,10 @@ import logger from "../utils/logger";
/**
*
*/
export const getExclusions = async (req: Request, res: Response) => {
export const getExclusions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
@ -42,7 +46,10 @@ export const getExclusions = async (req: Request, res: Response) => {
const result = await query(sql, params);
logger.info("상호 배제 규칙 목록 조회", { count: result.length, companyCode });
logger.info("상호 배제 규칙 목록 조회", {
count: result.length,
companyCode,
});
res.json({
success: true,
@ -61,7 +68,10 @@ export const getExclusions = async (req: Request, res: Response) => {
/**
*
*/
export const getExclusionDetail = async (req: Request, res: Response) => {
export const getExclusionDetail = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -116,7 +126,10 @@ const generateExclusionCode = async (companyCode: string): Promise<string> => {
/**
*
*/
export const createExclusion = async (req: Request, res: Response) => {
export const createExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const {
@ -133,7 +146,8 @@ export const createExclusion = async (req: Request, res: Response) => {
if (!exclusionName || !fieldNames || !sourceTable || !valueColumn) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
message:
"필수 필드가 누락되었습니다. (exclusionName, fieldNames, sourceTable, valueColumn)",
});
}
@ -195,7 +209,10 @@ export const createExclusion = async (req: Request, res: Response) => {
/**
*
*/
export const updateExclusion = async (req: Request, res: Response) => {
export const updateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -274,7 +291,10 @@ export const updateExclusion = async (req: Request, res: Response) => {
/**
*
*/
export const deleteExclusion = async (req: Request, res: Response) => {
export const deleteExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionId } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -322,7 +342,10 @@ export const deleteExclusion = async (req: Request, res: Response) => {
*
*
*/
export const validateExclusion = async (req: Request, res: Response) => {
export const validateExclusion = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { fieldValues } = req.body; // { "source_warehouse": "WH001", "target_warehouse": "WH002" }
@ -347,7 +370,9 @@ export const validateExclusion = async (req: Request, res: Response) => {
}
// 필드명 파싱
const fields = exclusion.field_names.split(",").map((f: string) => f.trim());
const fields = exclusion.field_names
.split(",")
.map((f: string) => f.trim());
// 필드 값 수집
const values: string[] = [];
@ -418,7 +443,10 @@ export const validateExclusion = async (req: Request, res: Response) => {
*
*
*/
export const getExcludedOptions = async (req: Request, res: Response) => {
export const getExcludedOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { exclusionCode } = req.params;
const { currentField, selectedValues } = req.query; // selectedValues: 이미 선택된 값들 (콤마 구분)
@ -470,9 +498,14 @@ export const getExcludedOptions = async (req: Request, res: Response) => {
// 이미 선택된 값 제외
if (selectedValues) {
const excludeValues = (selectedValues as string).split(",").map((v) => v.trim()).filter((v) => v);
const excludeValues = (selectedValues as string)
.split(",")
.map((v) => v.trim())
.filter((v) => v);
if (excludeValues.length > 0) {
const placeholders = excludeValues.map((_, i) => `$${optionsParamIndex + i}`).join(",");
const placeholders = excludeValues
.map((_, i) => `$${optionsParamIndex + i}`)
.join(",");
optionsSql += ` AND ${exclusion.value_column} NOT IN (${placeholders})`;
optionsParams.push(...excludeValues);
}
@ -502,4 +535,3 @@ export const getExcludedOptions = async (req: Request, res: Response) => {
});
}
};

View File

@ -1,4 +1,5 @@
import { Request, Response } from "express";
import { Response } from "express";
import { AuthenticatedRequest } from "../types/auth";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
@ -7,7 +8,10 @@ const pool = getPool();
/**
*
*/
export const getCascadingRelations = async (req: Request, res: Response) => {
export const getCascadingRelations = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const { isActive } = req.query;
@ -86,7 +90,10 @@ export const getCascadingRelations = async (req: Request, res: Response) => {
/**
*
*/
export const getCascadingRelationById = async (req: Request, res: Response) => {
export const getCascadingRelationById = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -155,7 +162,7 @@ export const getCascadingRelationById = async (req: Request, res: Response) => {
*
*/
export const getCascadingRelationByCode = async (
req: Request,
req: AuthenticatedRequest,
res: Response
) => {
try {
@ -223,7 +230,10 @@ export const getCascadingRelationByCode = async (
/**
*
*/
export const createCascadingRelation = async (req: Request, res: Response) => {
export const createCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId || "system";
@ -350,7 +360,10 @@ export const createCascadingRelation = async (req: Request, res: Response) => {
/**
*
*/
export const updateCascadingRelation = async (req: Request, res: Response) => {
export const updateCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -474,7 +487,10 @@ export const updateCascadingRelation = async (req: Request, res: Response) => {
/**
*
*/
export const deleteCascadingRelation = async (req: Request, res: Response) => {
export const deleteCascadingRelation = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { id } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -536,7 +552,10 @@ export const deleteCascadingRelation = async (req: Request, res: Response) => {
* 🆕 ( )
* parent_table에서 .
*/
export const getParentOptions = async (req: Request, res: Response) => {
export const getParentOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const companyCode = req.user?.companyCode || "*";
@ -644,7 +663,10 @@ export const getParentOptions = async (req: Request, res: Response) => {
*
* API
*/
export const getCascadingOptions = async (req: Request, res: Response) => {
export const getCascadingOptions = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { code } = req.params;
const { parentValue } = req.query;

View File

@ -1,43 +1,25 @@
import { Request, Response } from "express";
import { pool, queryOne } from "../database/db";
import logger from "../utils/logger";
import { PasswordEncryption } from "../utils/passwordEncryption";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { ExternalDbConnectionPoolService } from "../services/externalDbConnectionPoolService";
// 외부 DB 커넥터를 가져오는 헬퍼 함수
// 외부 DB 커넥터를 가져오는 헬퍼 함수 (연결 풀 사용)
export async function getExternalDbConnector(connectionId: number) {
// 외부 DB 연결 정보 조회
const connection = await queryOne<any>(
`SELECT * FROM external_db_connections WHERE id = $1`,
[connectionId]
);
const poolService = ExternalDbConnectionPoolService.getInstance();
if (!connection) {
throw new Error(`외부 DB 연결 정보를 찾을 수 없습니다. ID: ${connectionId}`);
}
// 패스워드 복호화
const decryptedPassword = PasswordEncryption.decrypt(connection.password);
// DB 연결 설정
const config = {
host: connection.host,
port: connection.port,
user: connection.username,
password: decryptedPassword,
database: connection.database_name,
// 연결 풀 래퍼를 반환 (executeQuery 메서드를 가진 객체)
return {
executeQuery: async (sql: string, params?: any[]) => {
const result = await poolService.executeQuery(connectionId, sql, params);
return { rows: result };
},
};
// DB 커넥터 생성
return await DatabaseConnectorFactory.createConnector(
connection.db_type || "mariadb",
config,
connectionId
);
}
// 동적 계층 구조 데이터 조회 (범용)
export const getHierarchyData = async (req: Request, res: Response): Promise<Response> => {
export const getHierarchyData = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig } = req.body;
@ -48,7 +30,9 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig);
const result: any = {
@ -69,7 +53,7 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
for (const level of config.levels) {
const levelQuery = `SELECT * FROM ${level.tableName} LIMIT 1000`;
const levelResult = await connector.executeQuery(levelQuery);
result.levels.push({
level: level.level,
name: level.name,
@ -94,7 +78,10 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
logger.info("동적 계층 구조 데이터 조회", {
externalDbConnectionId,
warehouseCount: result.warehouse?.length || 0,
levelCounts: result.levels.map((l: any) => ({ level: l.level, count: l.data.length })),
levelCounts: result.levels.map((l: any) => ({
level: l.level,
count: l.data.length,
})),
});
return res.json({
@ -112,22 +99,35 @@ export const getHierarchyData = async (req: Request, res: Response): Promise<Res
};
// 특정 레벨의 하위 데이터 조회
export const getChildrenData = async (req: Request, res: Response): Promise<Response> => {
export const getChildrenData = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } = req.body;
const { externalDbConnectionId, hierarchyConfig, parentLevel, parentKey } =
req.body;
if (!externalDbConnectionId || !hierarchyConfig || !parentLevel || !parentKey) {
if (
!externalDbConnectionId ||
!hierarchyConfig ||
!parentLevel ||
!parentKey
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const config = JSON.parse(hierarchyConfig);
// 다음 레벨 찾기
const nextLevel = config.levels?.find((l: any) => l.level === parentLevel + 1);
const nextLevel = config.levels?.find(
(l: any) => l.level === parentLevel + 1
);
if (!nextLevel) {
return res.json({
@ -168,7 +168,10 @@ export const getChildrenData = async (req: Request, res: Response): Promise<Resp
};
// 창고 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getWarehouses = async (req: Request, res: Response): Promise<Response> => {
export const getWarehouses = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, tableName } = req.query;
@ -186,7 +189,9 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 테이블명을 사용하여 모든 컬럼 조회
const query = `SELECT * FROM ${tableName} LIMIT 100`;
@ -215,7 +220,10 @@ export const getWarehouses = async (req: Request, res: Response): Promise<Respon
};
// 구역 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getAreas = async (req: Request, res: Response): Promise<Response> => {
export const getAreas = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, warehouseKey, tableName } = req.query;
@ -226,7 +234,9 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = `
SELECT * FROM ${tableName}
@ -258,7 +268,10 @@ export const getAreas = async (req: Request, res: Response): Promise<Response> =
};
// 위치 목록 조회 (사용자 지정 테이블) - 레거시, 호환성 유지
export const getLocations = async (req: Request, res: Response): Promise<Response> => {
export const getLocations = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, areaKey, tableName } = req.query;
@ -269,7 +282,9 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const query = `
SELECT * FROM ${tableName}
@ -301,28 +316,38 @@ export const getLocations = async (req: Request, res: Response): Promise<Respons
};
// 자재 목록 조회 (동적 컬럼 매핑 지원)
export const getMaterials = async (req: Request, res: Response): Promise<Response> => {
export const getMaterials = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const {
externalDbConnectionId,
locaKey,
const {
externalDbConnectionId,
locaKey,
tableName,
keyColumn,
locationKeyColumn,
layerColumn
layerColumn,
} = req.query;
if (!externalDbConnectionId || !locaKey || !tableName || !locationKeyColumn) {
if (
!externalDbConnectionId ||
!locaKey ||
!tableName ||
!locationKeyColumn
) {
return res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다.",
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
// 동적 쿼리 생성
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : '';
const orderByClause = layerColumn ? `ORDER BY ${layerColumn}` : "";
const query = `
SELECT * FROM ${tableName}
WHERE ${locationKeyColumn} = '${locaKey}'
@ -356,7 +381,10 @@ export const getMaterials = async (req: Request, res: Response): Promise<Respons
};
// 자재 개수 조회 (여러 Location 일괄) - 레거시, 호환성 유지
export const getMaterialCounts = async (req: Request, res: Response): Promise<Response> => {
export const getMaterialCounts = async (
req: Request,
res: Response
): Promise<Response> => {
try {
const { externalDbConnectionId, locationKeys, tableName } = req.body;
@ -367,7 +395,9 @@ export const getMaterialCounts = async (req: Request, res: Response): Promise<Re
});
}
const connector = await getExternalDbConnector(Number(externalDbConnectionId));
const connector = await getExternalDbConnector(
Number(externalDbConnectionId)
);
const keysString = locationKeys.map((key: string) => `'${key}'`).join(",");

View File

@ -134,8 +134,8 @@ export class EntityJoinService {
`🔧 기존 display_column 사용: ${column.column_name}${displayColumn}`
);
} else {
// display_column이 "none"이거나 없는 경우 참조 테이블의 모든 컬럼 가져오기
logger.info(`🔍 ${referenceTable}모든 컬럼 조회 중...`);
// display_column이 "none"이거나 없는 경우 참조 테이블의 표시용 컬럼 자동 감지
logger.info(`🔍 ${referenceTable}표시 컬럼 자동 감지 중...`);
// 참조 테이블의 모든 컬럼 이름 가져오기
const tableColumnsResult = await query<{ column_name: string }>(
@ -148,10 +148,34 @@ export class EntityJoinService {
);
if (tableColumnsResult.length > 0) {
displayColumns = tableColumnsResult.map((col) => col.column_name);
const allColumns = tableColumnsResult.map((col) => col.column_name);
// 🆕 표시용 컬럼 자동 감지 (우선순위 순서)
// 1. *_name 컬럼 (item_name, customer_name 등)
// 2. name 컬럼
// 3. label 컬럼
// 4. title 컬럼
// 5. 참조 컬럼 (referenceColumn)
const nameColumn = allColumns.find(
(col) => col.endsWith("_name") && col !== "company_name"
);
const simpleNameColumn = allColumns.find((col) => col === "name");
const labelColumn = allColumns.find(
(col) => col === "label" || col.endsWith("_label")
);
const titleColumn = allColumns.find((col) => col === "title");
// 우선순위에 따라 표시 컬럼 선택
const displayColumn =
nameColumn ||
simpleNameColumn ||
labelColumn ||
titleColumn ||
referenceColumn;
displayColumns = [displayColumn];
logger.info(
`${referenceTable}의 모든 컬럼 자동 포함 (${displayColumns.length}개):`,
displayColumns.join(", ")
`${referenceTable}의 표시 컬럼 자동 감지: ${displayColumn} (전체 ${allColumns.length}개 중)`
);
} else {
// 테이블 컬럼을 못 찾으면 기본값 사용

View File

@ -113,6 +113,7 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
lastUsedAt: Date;
activeConnections = 0;
maxConnections: number;
private isPoolClosed = false;
constructor(config: ExternalDbConnection) {
this.connectionId = config.id!;
@ -131,6 +132,9 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
waitForConnections: true,
queueLimit: 0,
connectTimeout: (config.connection_timeout || 30) * 1000,
// 연결 유지 및 자동 재연결 설정
enableKeepAlive: true,
keepAliveInitialDelay: 10000, // 10초마다 keep-alive 패킷 전송
ssl:
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
});
@ -153,11 +157,33 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
async query(sql: string, params?: any[]): Promise<any> {
this.lastUsedAt = new Date();
const [rows] = await this.pool.execute(sql, params);
return rows;
// 연결 풀이 닫힌 상태인지 확인
if (this.isPoolClosed) {
throw new Error("연결 풀이 닫힌 상태입니다. 재연결이 필요합니다.");
}
try {
const [rows] = await this.pool.execute(sql, params);
return rows;
} catch (error: any) {
// 연결 닫힘 오류 감지
if (
error.message.includes("closed state") ||
error.code === "PROTOCOL_CONNECTION_LOST" ||
error.code === "ECONNRESET"
) {
this.isPoolClosed = true;
logger.warn(
`[${this.dbType.toUpperCase()}] 연결 끊김 감지 (ID: ${this.connectionId})`
);
}
throw error;
}
}
async disconnect(): Promise<void> {
this.isPoolClosed = true;
await this.pool.end();
logger.info(
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
@ -165,6 +191,10 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper {
}
isHealthy(): boolean {
// 연결 풀이 닫혔으면 비정상
if (this.isPoolClosed) {
return false;
}
return this.activeConnections < this.maxConnections;
}
}
@ -230,9 +260,11 @@ export class ExternalDbConnectionPoolService {
): Promise<ConnectionPoolWrapper> {
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
// DB 연결 정보 조회
// DB 연결 정보 조회 (실제 비밀번호 포함)
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
await ExternalDbConnectionService.getConnectionByIdWithPassword(
connectionId
);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
@ -296,16 +328,19 @@ export class ExternalDbConnectionPoolService {
}
/**
* ( )
* ( + )
*/
async executeQuery(
connectionId: number,
sql: string,
params?: any[]
params?: any[],
retryCount = 0
): Promise<any> {
const pool = await this.getPool(connectionId);
const MAX_RETRIES = 2;
try {
const pool = await this.getPool(connectionId);
logger.debug(
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
);
@ -314,7 +349,29 @@ export class ExternalDbConnectionPoolService {
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}`
);
return result;
} catch (error) {
} catch (error: any) {
// 연결 끊김 오류인 경우 재시도
const isConnectionError =
error.message?.includes("closed state") ||
error.message?.includes("연결 풀이 닫힌 상태") ||
error.code === "PROTOCOL_CONNECTION_LOST" ||
error.code === "ECONNRESET" ||
error.code === "ETIMEDOUT";
if (isConnectionError && retryCount < MAX_RETRIES) {
logger.warn(
`🔄 연결 오류 감지, 재시도 중... (${retryCount + 1}/${MAX_RETRIES}) (ID: ${connectionId})`
);
// 기존 풀 제거 후 새로 생성
await this.removePool(connectionId);
// 잠시 대기 후 재시도
await new Promise((resolve) => setTimeout(resolve, 500));
return this.executeQuery(connectionId, sql, params, retryCount + 1);
}
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
throw error;
}

View File

@ -3596,7 +3596,7 @@ export class NodeFlowExecutionService {
// 계정 ID 우선순위: nodeAccountId > smtpConfigId > 첫 번째 활성 계정
let accountId = nodeAccountId || smtpConfigId;
if (!accountId) {
const accounts = await mailAccountFileService.getAccounts();
const accounts = await mailAccountFileService.getAllAccounts();
const activeAccount = accounts.find(
(acc: any) => acc.status === "active"
);
@ -4216,7 +4216,7 @@ export class NodeFlowExecutionService {
return this.evaluateFunction(func, sourceRow, targetRow, resultValues);
case "condition":
return this.evaluateCondition(
return this.evaluateCaseCondition(
condition,
sourceRow,
targetRow,
@ -4393,7 +4393,7 @@ export class NodeFlowExecutionService {
/**
* (CASE WHEN ... THEN ... ELSE)
*/
private static evaluateCondition(
private static evaluateCaseCondition(
condition: any,
sourceRow: any,
targetRow: any,

View File

@ -798,7 +798,12 @@ export class TableManagementService {
);
// 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트
await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode);
await this.syncScreenLayoutsInputType(
tableName,
columnName,
inputType,
companyCode
);
// 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제
const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`;
@ -928,7 +933,11 @@ export class TableManagementService {
`UPDATE screen_layouts
SET properties = $1, component_type = $2
WHERE layout_id = $3`,
[JSON.stringify(updatedProperties), newComponentType, layout.layout_id]
[
JSON.stringify(updatedProperties),
newComponentType,
layout.layout_id,
]
);
logger.info(
@ -1299,18 +1308,30 @@ export class TableManagementService {
try {
// 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
// 날짜 타입이면 날짜 범위로 처리
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
const multiValues = value
.split("|")
.filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
const placeholders = multiValues
.map((_: string, idx: number) => `$${paramIndex + idx}`)
.join(", ");
logger.info(
`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`
);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
@ -1320,10 +1341,20 @@ export class TableManagementService {
}
// 🔧 날짜 범위 객체 {from, to} 체크
if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) {
if (
typeof value === "object" &&
value !== null &&
("from" in value || "to" in value)
) {
// 날짜 범위 객체는 그대로 전달
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
const columnInfo = await this.getColumnWebTypeInfo(
tableName,
columnName
);
if (
columnInfo &&
(columnInfo.webType === "date" || columnInfo.webType === "datetime")
) {
return this.buildDateRangeCondition(columnName, value, paramIndex);
}
}
@ -1356,9 +1387,10 @@ export class TableManagementService {
// 컬럼 타입 정보 조회
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
`webType=${columnInfo?.webType || 'NULL'}`,
`inputType=${columnInfo?.inputType || 'NULL'}`,
logger.info(
`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
`webType=${columnInfo?.webType || "NULL"}`,
`inputType=${columnInfo?.inputType || "NULL"}`,
`actualValue=${JSON.stringify(actualValue)}`,
`operator=${operator}`
);
@ -1464,16 +1496,20 @@ export class TableManagementService {
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
if (typeof value === "string" && value.includes("|")) {
const [fromStr, toStr] = value.split("|");
if (fromStr && fromStr.trim() !== "") {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
values.push(fromStr.trim());
paramCount++;
}
if (toStr && toStr.trim() !== "") {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
values.push(toStr.trim());
paramCount++;
}
@ -1482,17 +1518,21 @@ export class TableManagementService {
else if (typeof value === "object" && value !== null) {
if (value.from) {
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
conditions.push(
`${columnName}::date >= $${paramIndex + paramCount}::date`
);
values.push(value.from);
paramCount++;
}
if (value.to) {
// 종료일은 해당 날짜의 23:59:59까지 포함
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
conditions.push(
`${columnName}::date <= $${paramIndex + paramCount}::date`
);
values.push(value.to);
paramCount++;
}
}
}
// 단일 날짜 검색
else if (typeof value === "string" && value.trim() !== "") {
conditions.push(`${columnName}::date = $${paramIndex}::date`);
@ -1658,9 +1698,11 @@ export class TableManagementService {
paramCount: 0,
};
}
// IN 절로 여러 값 검색
const placeholders = value.map((_, idx) => `$${paramIndex + idx}`).join(", ");
const placeholders = value
.map((_, idx) => `$${paramIndex + idx}`)
.join(", ");
return {
whereClause: `${columnName} IN (${placeholders})`,
values: value,
@ -1776,20 +1818,25 @@ export class TableManagementService {
[tableName, columnName]
);
logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, {
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
});
logger.info(
`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`,
{
found: !!result,
web_type: result?.web_type,
input_type: result?.input_type,
}
);
if (!result) {
logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`);
logger.warn(
`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`
);
return null;
}
// web_type이 없으면 input_type을 사용 (레거시 호환)
const webType = result.web_type || result.input_type || "";
const columnInfo = {
webType: webType,
inputType: result.input_type || "",
@ -1799,7 +1846,9 @@ export class TableManagementService {
displayColumn: result.display_column || undefined,
};
logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`);
logger.info(
`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`
);
return columnInfo;
} catch (error) {
logger.error(
@ -1913,6 +1962,15 @@ export class TableManagementService {
continue;
}
// 🆕 조인 테이블 컬럼 (테이블명.컬럼명)은 기본 데이터 조회에서 제외
// Entity 조인 조회에서만 처리됨
if (column.includes(".")) {
logger.info(
`🔍 조인 테이블 컬럼 ${column} 기본 조회에서 제외 (Entity 조인에서 처리)`
);
continue;
}
// 안전한 컬럼명 검증 (SQL 인젝션 방지)
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
@ -2741,7 +2799,11 @@ export class TableManagementService {
WHERE "${referenceColumn}" IS NOT NULL`;
// 추가 필터 조건이 있으면 적용 (예: 특정 거래처의 품목만 제외)
if (filterColumn && filterValue !== undefined && filterValue !== null) {
if (
filterColumn &&
filterValue !== undefined &&
filterValue !== null
) {
excludeSubquery += ` AND "${filterColumn}" = '${String(filterValue).replace(/'/g, "''")}'`;
}
@ -2934,16 +2996,22 @@ export class TableManagementService {
}),
];
// 🆕 테이블명.컬럼명 형식도 Entity 검색으로 인식
const hasJoinTableSearch =
options.search &&
Object.keys(options.search).some((key) => key.includes("."));
const hasEntitySearch =
options.search &&
Object.keys(options.search).some((key) =>
(Object.keys(options.search).some((key) =>
allEntityColumns.includes(key)
);
) ||
hasJoinTableSearch);
if (hasEntitySearch) {
const entitySearchKeys = options.search
? Object.keys(options.search).filter((key) =>
allEntityColumns.includes(key)
? Object.keys(options.search).filter(
(key) => allEntityColumns.includes(key) || key.includes(".")
)
: [];
logger.info(
@ -2988,47 +3056,113 @@ export class TableManagementService {
if (options.search) {
for (const [key, value] of Object.entries(options.search)) {
// 검색값 추출 (객체 형태일 수 있음)
let searchValue = value;
if (
typeof value === "object" &&
value !== null &&
"value" in value
) {
searchValue = value.value;
}
// 빈 값이면 스킵
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null ||
searchValue === undefined
) {
continue;
}
const safeValue = String(searchValue).replace(/'/g, "''");
// 🆕 테이블명.컬럼명 형식 처리 (예: item_info.item_name)
if (key.includes(".")) {
const [refTable, refColumn] = key.split(".");
// aliasMap에서 별칭 찾기 (테이블명:소스컬럼 형식)
let foundAlias: string | undefined;
for (const [aliasKey, alias] of aliasMap.entries()) {
if (aliasKey.startsWith(`${refTable}:`)) {
foundAlias = alias;
break;
}
}
if (foundAlias) {
whereConditions.push(
`${foundAlias}.${refColumn}::text ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (${refTable}.${refColumn})`);
logger.info(
`🎯 조인 테이블 검색: ${key}${refTable}.${refColumn} LIKE '%${safeValue}%' (별칭: ${foundAlias})`
);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${key} - 별칭을 찾을 수 없음`
);
}
continue;
}
const joinConfig = joinConfigs.find(
(config) => config.aliasColumn === key
);
if (joinConfig) {
// 기본 Entity 조인 컬럼인 경우: 조인된 테이블의 표시 컬럼에서 검색
const alias = aliasMap.get(joinConfig.referenceTable);
const aliasKey = `${joinConfig.referenceTable}:${joinConfig.sourceColumn}`;
const alias = aliasMap.get(aliasKey);
whereConditions.push(
`${alias}.${joinConfig.displayColumn} ILIKE '%${value}%'`
`${alias}.${joinConfig.displayColumn} ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(
`${key} (${joinConfig.referenceTable}.${joinConfig.displayColumn})`
);
logger.info(
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${value}%' (별칭: ${alias})`
`🎯 Entity 조인 검색: ${key}${joinConfig.referenceTable}.${joinConfig.displayColumn} LIKE '%${safeValue}%' (별칭: ${alias})`
);
} else if (key === "writer_dept_code") {
// writer_dept_code: user_info.dept_code에서 검색
const userAlias = aliasMap.get("user_info");
whereConditions.push(
`${userAlias}.dept_code ILIKE '%${value}%'`
);
entitySearchColumns.push(`${key} (user_info.dept_code)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${value}%' (별칭: ${userAlias})`
const userAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("user_info:")
);
const userAlias = userAliasKey
? aliasMap.get(userAliasKey)
: undefined;
if (userAlias) {
whereConditions.push(
`${userAlias}.dept_code ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (user_info.dept_code)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → user_info.dept_code LIKE '%${safeValue}%' (별칭: ${userAlias})`
);
}
} else if (key === "company_code_status") {
// company_code_status: company_info.status에서 검색
const companyAlias = aliasMap.get("company_info");
whereConditions.push(
`${companyAlias}.status ILIKE '%${value}%'`
);
entitySearchColumns.push(`${key} (company_info.status)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${value}%' (별칭: ${companyAlias})`
const companyAliasKey = Array.from(aliasMap.keys()).find((k) =>
k.startsWith("company_info:")
);
const companyAlias = companyAliasKey
? aliasMap.get(companyAliasKey)
: undefined;
if (companyAlias) {
whereConditions.push(
`${companyAlias}.status ILIKE '%${safeValue}%'`
);
entitySearchColumns.push(`${key} (company_info.status)`);
logger.info(
`🎯 추가 Entity 조인 검색: ${key} → company_info.status LIKE '%${safeValue}%' (별칭: ${companyAlias})`
);
}
} else {
// 일반 컬럼인 경우: 메인 테이블에서 검색
whereConditions.push(`main.${key} ILIKE '%${value}%'`);
whereConditions.push(`main.${key} ILIKE '%${safeValue}%'`);
logger.info(
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${value}%'`
`🔍 일반 컬럼 검색: ${key} → main.${key} LIKE '%${safeValue}%'`
);
}
}
@ -3168,6 +3302,59 @@ export class TableManagementService {
}
try {
// 🆕 조인 테이블 컬럼 검색 처리 (예: item_info.item_name)
if (columnName.includes(".")) {
const [refTable, refColumn] = columnName.split(".");
// 검색값 추출
let searchValue = value;
if (typeof value === "object" && value !== null && "value" in value) {
searchValue = value.value;
}
if (
searchValue === "__ALL__" ||
searchValue === "" ||
searchValue === null
) {
continue;
}
// 🔍 column_labels에서 해당 엔티티 설정 찾기
// 예: item_info 테이블을 참조하는 컬럼 찾기 (item_code → item_info)
const entityColumnResult = await query<{
column_name: string;
reference_table: string;
reference_column: string;
}>(
`SELECT column_name, reference_table, reference_column
FROM column_labels
WHERE table_name = $1
AND input_type = 'entity'
AND reference_table = $2
LIMIT 1`,
[tableName, refTable]
);
if (entityColumnResult.length > 0) {
// 조인 별칭 생성 (entityJoinService.ts와 동일한 패턴: 테이블명 앞 3글자)
const joinAlias = refTable.substring(0, 3);
// 조인 테이블 컬럼으로 검색 조건 생성
const safeValue = String(searchValue).replace(/'/g, "''");
const condition = `${joinAlias}.${refColumn}::text ILIKE '%${safeValue}%'`;
logger.info(`🔍 조인 테이블 검색 조건: ${condition}`);
conditions.push(condition);
} else {
logger.warn(
`⚠️ 조인 테이블 검색 실패: ${columnName} - 엔티티 설정을 찾을 수 없음`
);
}
continue;
}
// 고급 검색 조건 구성
const searchCondition = await this.buildAdvancedSearchCondition(
tableName,
@ -4282,7 +4469,10 @@ export class TableManagementService {
);
return result.length > 0;
} catch (error) {
logger.error(`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`, error);
logger.error(
`컬럼 존재 여부 확인 실패: ${tableName}.${columnName}`,
error
);
return false;
}
}

View File

@ -10,17 +10,13 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Checkbox } from "@/components/ui/checkbox";
import { Plus, X } from "lucide-react";
import { TableFilter } from "@/types/table-options";
import { Layers } from "lucide-react";
import { TableFilter, GroupSumConfig } from "@/types/table-options";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface Props {
isOpen: boolean;
@ -77,17 +73,37 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
const [columnFilters, setColumnFilters] = useState<ColumnFilterConfig[]>([]);
const [selectAll, setSelectAll] = useState(false);
// 🆕 그룹별 합산 설정
const [groupSumEnabled, setGroupSumEnabled] = useState(false);
const [groupByColumn, setGroupByColumn] = useState<string>("");
// localStorage에서 저장된 필터 설정 불러오기
useEffect(() => {
if (table?.columns && table?.tableName) {
// 화면별로 독립적인 필터 설정 저장
const storageKey = screenId
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
const savedFilters = localStorage.getItem(storageKey);
// 🆕 그룹핑 설정도 불러오기
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
const savedGroupSum = localStorage.getItem(groupSumKey);
if (savedGroupSum) {
try {
const parsed = JSON.parse(savedGroupSum) as GroupSumConfig;
setGroupSumEnabled(parsed.enabled);
setGroupByColumn(parsed.groupByColumn || "");
} catch (error) {
console.error("그룹핑 설정 불러오기 실패:", error);
}
}
let filters: ColumnFilterConfig[];
if (savedFilters) {
try {
const parsed = JSON.parse(savedFilters) as ColumnFilterConfig[];
@ -96,13 +112,15 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
.filter((col) => col.filterable !== false)
.map((col) => {
const saved = parsed.find((f) => f.columnName === col.columnName);
return saved || {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType: col.inputType || "text",
enabled: false,
filterType: mapInputTypeToFilterType(col.inputType || "text"),
};
return (
saved || {
columnName: col.columnName,
columnLabel: col.columnLabel,
inputType: col.inputType || "text",
enabled: false,
filterType: mapInputTypeToFilterType(col.inputType || "text"),
}
);
});
} catch (error) {
console.error("저장된 필터 설정 불러오기 실패:", error);
@ -127,26 +145,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
filterType: mapInputTypeToFilterType(col.inputType || "text"),
}));
}
setColumnFilters(filters);
}
}, [table?.columns, table?.tableName]);
// inputType을 filterType으로 매핑
const mapInputTypeToFilterType = (
inputType: string
): "text" | "number" | "date" | "select" => {
const mapInputTypeToFilterType = (inputType: string): "text" | "number" | "date" | "select" => {
if (inputType.includes("number") || inputType.includes("decimal")) {
return "number";
}
if (inputType.includes("date") || inputType.includes("time")) {
return "date";
}
if (
inputType.includes("select") ||
inputType.includes("code") ||
inputType.includes("category")
) {
if (inputType.includes("select") || inputType.includes("code") || inputType.includes("category")) {
return "select";
}
return "text";
@ -155,31 +167,20 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
// 전체 선택/해제
const toggleSelectAll = (checked: boolean) => {
setSelectAll(checked);
setColumnFilters((prev) =>
prev.map((filter) => ({ ...filter, enabled: checked }))
);
setColumnFilters((prev) => prev.map((filter) => ({ ...filter, enabled: checked })));
};
// 개별 필터 토글
const toggleFilter = (columnName: string) => {
setColumnFilters((prev) =>
prev.map((filter) =>
filter.columnName === columnName
? { ...filter, enabled: !filter.enabled }
: filter
)
prev.map((filter) => (filter.columnName === columnName ? { ...filter, enabled: !filter.enabled } : filter)),
);
};
// 필터 타입 변경
const updateFilterType = (
columnName: string,
filterType: "text" | "number" | "date" | "select"
) => {
const updateFilterType = (columnName: string, filterType: "text" | "number" | "date" | "select") => {
setColumnFilters((prev) =>
prev.map((filter) =>
filter.columnName === columnName ? { ...filter, filterType } : filter
)
prev.map((filter) => (filter.columnName === columnName ? { ...filter, filterType } : filter)),
);
};
@ -198,44 +199,76 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
// localStorage에 저장 (화면별로 독립적)
if (table?.tableName) {
const storageKey = screenId
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.setItem(storageKey, JSON.stringify(columnFilters));
// 🆕 그룹핑 설정 저장
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
if (groupSumEnabled && groupByColumn) {
const selectedColumn = columnFilters.find((f) => f.columnName === groupByColumn);
const groupSumConfig: GroupSumConfig = {
enabled: true,
groupByColumn: groupByColumn,
groupByColumnLabel: selectedColumn?.columnLabel,
};
localStorage.setItem(groupSumKey, JSON.stringify(groupSumConfig));
table?.onGroupSumChange?.(groupSumConfig);
} else {
localStorage.removeItem(groupSumKey);
table?.onGroupSumChange?.(null);
}
}
table?.onFilterChange(activeFilters);
// 콜백으로 활성화된 필터 정보 전달
onFiltersApplied?.(activeFilters);
onClose();
};
// 초기화 (즉시 저장 및 적용)
const clearFilters = () => {
const clearedFilters = columnFilters.map((filter) => ({
...filter,
enabled: false
const clearedFilters = columnFilters.map((filter) => ({
...filter,
enabled: false,
}));
setColumnFilters(clearedFilters);
setSelectAll(false);
// 🆕 그룹핑 설정 초기화
setGroupSumEnabled(false);
setGroupByColumn("");
// localStorage에서 제거 (화면별로 독립적)
if (table?.tableName) {
const storageKey = screenId
const storageKey = screenId
? `table_filters_${table.tableName}_screen_${screenId}`
: `table_filters_${table.tableName}`;
localStorage.removeItem(storageKey);
// 🆕 그룹핑 설정도 제거
const groupSumKey = screenId
? `table_groupsum_${table.tableName}_screen_${screenId}`
: `table_groupsum_${table.tableName}`;
localStorage.removeItem(groupSumKey);
}
// 빈 필터 배열로 적용
table?.onFilterChange([]);
// 🆕 그룹핑 해제
table?.onGroupSumChange?.(null);
// 콜백으로 빈 필터 정보 전달
onFiltersApplied?.([]);
// 즉시 닫기
onClose();
};
@ -246,9 +279,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg">
</DialogTitle>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription>
@ -256,17 +287,12 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
<div className="space-y-3 sm:space-y-4">
{/* 전체 선택/해제 */}
<div className="flex items-center justify-between rounded-lg border bg-muted/30 p-3">
<div className="bg-muted/30 flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<Checkbox
checked={selectAll}
onCheckedChange={(checked) =>
toggleSelectAll(checked as boolean)
}
/>
<Checkbox checked={selectAll} onCheckedChange={(checked) => toggleSelectAll(checked as boolean)} />
<span className="text-sm font-medium"> /</span>
</div>
<div className="text-xs text-muted-foreground">
<div className="text-muted-foreground text-xs">
{enabledCount} / {columnFilters.length}
</div>
</div>
@ -277,30 +303,21 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
{columnFilters.map((filter) => (
<div
key={filter.columnName}
className="flex items-center gap-3 rounded-lg border bg-background p-3 transition-colors hover:bg-muted/50"
className="bg-background hover:bg-muted/50 flex items-center gap-3 rounded-lg border p-3 transition-colors"
>
{/* 체크박스 */}
<Checkbox
checked={filter.enabled}
onCheckedChange={() => toggleFilter(filter.columnName)}
/>
<Checkbox checked={filter.enabled} onCheckedChange={() => toggleFilter(filter.columnName)} />
{/* 컬럼 정보 */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{filter.columnLabel}
</div>
<div className="truncate text-xs text-muted-foreground">
{filter.columnName}
</div>
<div className="truncate text-sm font-medium">{filter.columnLabel}</div>
<div className="text-muted-foreground truncate text-xs">{filter.columnName}</div>
</div>
{/* 필터 타입 선택 */}
<Select
value={filter.filterType}
onValueChange={(val: any) =>
updateFilterType(filter.columnName, val)
}
onValueChange={(val: any) => updateFilterType(filter.columnName, val)}
disabled={!filter.enabled}
>
<SelectTrigger className="h-8 w-[110px] text-xs sm:h-9 sm:text-sm">
@ -321,11 +338,7 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
onChange={(e) => {
const newWidth = parseInt(e.target.value) || 200;
setColumnFilters((prev) =>
prev.map((f) =>
f.columnName === filter.columnName
? { ...f, width: newWidth }
: f
)
prev.map((f) => (f.columnName === filter.columnName ? { ...f, width: newWidth } : f)),
);
}}
disabled={!filter.enabled}
@ -334,31 +347,56 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
min={50}
max={500}
/>
<span className="text-xs text-muted-foreground">px</span>
<span className="text-muted-foreground text-xs">px</span>
</div>
))}
</div>
</ScrollArea>
{/* 🆕 그룹별 합산 설정 */}
<div className="bg-muted/30 space-y-3 rounded-lg border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Layers className="text-muted-foreground h-4 w-4" />
<Label htmlFor="group-sum-toggle" className="cursor-pointer text-sm font-medium">
</Label>
</div>
<Switch id="group-sum-toggle" checked={groupSumEnabled} onCheckedChange={setGroupSumEnabled} />
</div>
{groupSumEnabled && (
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">
( )
</Label>
<Select value={groupByColumn} onValueChange={setGroupByColumn}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="그룹 기준 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columnFilters.map((filter) => (
<SelectItem key={filter.columnName} value={filter.columnName}>
{filter.columnLabel}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{/* 안내 메시지 */}
<div className="rounded-lg bg-muted/50 p-3 text-center text-xs text-muted-foreground">
<div className="bg-muted/50 text-muted-foreground rounded-lg p-3 text-center text-xs">
1
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="ghost"
onClick={clearFilters}
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<Button variant="ghost" onClick={clearFilters} className="h-8 text-xs sm:h-10 sm:text-sm">
</Button>
<Button
variant="outline"
onClick={onClose}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Button variant="outline" onClick={onClose} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
<Button
@ -373,4 +411,3 @@ export const FilterPanel: React.FC<Props> = ({ isOpen, onClose, onFiltersApplied
</Dialog>
);
};

View File

@ -34,7 +34,7 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { TableFilter, ColumnVisibility, GroupSumConfig } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
@ -73,12 +73,69 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
return true;
};
// 🆕 엔티티 조인 컬럼명 변환 헬퍼
// "테이블명.컬럼명" 형식을 "원본컬럼_조인컬럼명" 형식으로 변환하여 데이터 접근
const getEntityJoinValue = useCallback(
(item: any, columnName: string, entityColumnMap?: Record<string, string>): any => {
// 직접 매칭 시도
if (item[columnName] !== undefined) {
return item[columnName];
}
// "테이블명.컬럼명" 형식인 경우 (예: item_info.item_name)
if (columnName.includes(".")) {
const [tableName, fieldName] = columnName.split(".");
// 🔍 엔티티 조인 컬럼 값 추출
// 예: item_info.item_name, item_info.standard, item_info.unit
// 1⃣ 소스 컬럼 추론 (item_info → item_code, warehouse_info → warehouse_id 등)
const inferredSourceColumn = tableName.replace("_info", "_code").replace("_mng", "_id");
// 2⃣ 정확한 키 매핑 시도: 소스컬럼_필드명
// 예: item_code_item_name, item_code_standard, item_code_unit
const exactKey = `${inferredSourceColumn}_${fieldName}`;
if (item[exactKey] !== undefined) {
return item[exactKey];
}
// 3⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
// 예: item_code_name (item_name의 별칭)
if (fieldName === "item_name" || fieldName === "name") {
const aliasKey = `${inferredSourceColumn}_name`;
if (item[aliasKey] !== undefined) {
return item[aliasKey];
}
}
// 4⃣ entityColumnMap에서 매핑 찾기 (화면 설정에서 지정된 경우)
if (entityColumnMap && entityColumnMap[tableName]) {
const sourceColumn = entityColumnMap[tableName];
const joinedColumnName = `${sourceColumn}_${fieldName}`;
if (item[joinedColumnName] !== undefined) {
return item[joinedColumnName];
}
}
// 5⃣ 테이블명_컬럼명 형식으로 시도
const underscoreKey = `${tableName}_${fieldName}`;
if (item[underscoreKey] !== undefined) {
return item[underscoreKey];
}
}
return undefined;
},
[],
);
// TableOptions Context
const { registerTable, unregisterTable } = useTableOptions();
const [leftFilters, setLeftFilters] = useState<TableFilter[]>([]);
const [leftGrouping, setLeftGrouping] = useState<string[]>([]);
const [leftColumnVisibility, setLeftColumnVisibility] = useState<ColumnVisibility[]>([]);
const [leftColumnOrder, setLeftColumnOrder] = useState<string[]>([]); // 🔧 컬럼 순서
const [leftGroupSumConfig, setLeftGroupSumConfig] = useState<GroupSumConfig | null>(null); // 🆕 그룹별 합산 설정
const [rightFilters, setRightFilters] = useState<TableFilter[]>([]);
const [rightGrouping, setRightGrouping] = useState<string[]>([]);
const [rightColumnVisibility, setRightColumnVisibility] = useState<ColumnVisibility[]>([]);
@ -125,6 +182,88 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 🆕 그룹별 합산된 데이터 계산
const summedLeftData = useMemo(() => {
console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig);
// 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환
if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) {
console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환");
return leftData;
}
const groupByColumn = leftGroupSumConfig.groupByColumn;
const groupMap = new Map<string, any>();
// 조인 컬럼인지 확인하고 실제 키 추론
const getActualKey = (columnName: string, item: any): string => {
if (columnName.includes(".")) {
const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const exactKey = `${inferredSourceColumn}_${fieldName}`;
console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined });
if (item[exactKey] !== undefined) return exactKey;
if (fieldName === "item_name" || fieldName === "name") {
const aliasKey = `${inferredSourceColumn}_name`;
if (item[aliasKey] !== undefined) return aliasKey;
}
}
return columnName;
};
// 숫자 타입인지 확인하는 함수
const isNumericValue = (value: any): boolean => {
if (value === null || value === undefined || value === "") return false;
const num = parseFloat(String(value));
return !isNaN(num) && isFinite(num);
};
// 그룹핑 수행
leftData.forEach((item) => {
const actualKey = getActualKey(groupByColumn, item);
const groupValue = String(item[actualKey] || item[groupByColumn] || "");
// 원본 ID 추출 (id, ID, 또는 첫 번째 값)
const originalId = item.id || item.ID || Object.values(item)[0];
if (!groupMap.has(groupValue)) {
// 첫 번째 항목을 기준으로 초기화 + 원본 ID 배열 + 원본 데이터 배열
groupMap.set(groupValue, {
...item,
_groupCount: 1,
_originalIds: [originalId],
_originalItems: [item], // 🆕 원본 데이터 전체 저장
});
} else {
const existing = groupMap.get(groupValue);
existing._groupCount += 1;
existing._originalIds.push(originalId);
existing._originalItems.push(item); // 🆕 원본 데이터 추가
// 모든 키에 대해 숫자면 합산
Object.keys(item).forEach((key) => {
const value = item[key];
if (isNumericValue(value) && key !== groupByColumn && !key.endsWith("_id") && !key.includes("code")) {
const numValue = parseFloat(String(value));
const existingValue = parseFloat(String(existing[key] || 0));
existing[key] = existingValue + numValue;
}
});
groupMap.set(groupValue, existing);
}
});
const result = Array.from(groupMap.values());
console.log("🔗 [분할패널] 그룹별 합산 결과:", {
원본개수: leftData.length,
그룹개수: result.length,
그룹기준: groupByColumn,
});
return result;
}, [leftData, leftGroupSumConfig]);
// 컴포넌트 스타일
// height 처리: 이미 px 단위면 그대로, 숫자면 px 추가
const getHeightValue = () => {
@ -433,14 +572,77 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 🎯 필터 조건을 API에 전달 (entityJoinApi 사용)
const filters = Object.keys(searchValues).length > 0 ? searchValues : undefined;
// 🆕 "테이블명.컬럼명" 형식의 조인 컬럼들을 additionalJoinColumns로 변환
const configuredColumns = componentConfig.leftPanel?.columns || [];
const additionalJoinColumns: Array<{
sourceTable: string;
sourceColumn: string;
referenceTable: string;
joinAlias: string;
}> = [];
// 소스 컬럼 매핑 (item_info → item_code, warehouse_info → warehouse_id 등)
const sourceColumnMap: Record<string, string> = {};
configuredColumns.forEach((col: any) => {
const colName = typeof col === "string" ? col : col.name || col.columnName;
if (colName && colName.includes(".")) {
const [refTable, refColumn] = colName.split(".");
// 소스 컬럼 추론 (item_info → item_code)
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
const existingJoin = additionalJoinColumns.find(
(j) => j.referenceTable === refTable && j.sourceColumn === inferredSourceColumn,
);
if (!existingJoin) {
// 새로운 조인 추가 (첫 번째 컬럼)
additionalJoinColumns.push({
sourceTable: leftTableName,
sourceColumn: inferredSourceColumn,
referenceTable: refTable,
joinAlias: `${inferredSourceColumn}_${refColumn}`,
});
sourceColumnMap[refTable] = inferredSourceColumn;
}
// 추가 컬럼도 별도로 요청 (item_code_standard, item_code_unit 등)
// 단, 첫 번째 컬럼과 다른 경우만
const existingAliases = additionalJoinColumns
.filter((j) => j.referenceTable === refTable)
.map((j) => j.joinAlias);
const newAlias = `${sourceColumnMap[refTable] || inferredSourceColumn}_${refColumn}`;
if (!existingAliases.includes(newAlias)) {
additionalJoinColumns.push({
sourceTable: leftTableName,
sourceColumn: sourceColumnMap[refTable] || inferredSourceColumn,
referenceTable: refTable,
joinAlias: newAlias,
});
}
}
});
console.log("🔗 [분할패널] additionalJoinColumns:", additionalJoinColumns);
console.log("🔗 [분할패널] configuredColumns:", configuredColumns);
const result = await entityJoinApi.getTableDataWithJoins(leftTableName, {
page: 1,
size: 100,
search: filters, // 필터 조건 전달
enableEntityJoin: true, // 엔티티 조인 활성화
dataFilter: componentConfig.leftPanel?.dataFilter, // 🆕 데이터 필터 전달
additionalJoinColumns: additionalJoinColumns.length > 0 ? additionalJoinColumns : undefined, // 🆕 추가 조인 컬럼
});
// 🔍 디버깅: API 응답 데이터의 키 확인
if (result.data && result.data.length > 0) {
console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0]));
console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]);
}
// 가나다순 정렬 (좌측 패널의 표시 컬럼 기준)
const leftColumn = componentConfig.rightPanel?.relation?.leftColumn;
if (leftColumn && result.data.length > 0) {
@ -466,6 +668,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}, [
componentConfig.leftPanel?.tableName,
componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn,
isDesignMode,
toast,
@ -502,6 +706,68 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const keys = componentConfig.rightPanel?.relation?.keys;
const leftTable = componentConfig.leftPanel?.tableName;
// 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시
if (leftItem._originalItems && leftItem._originalItems.length > 0) {
console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length);
// 정렬 기준 컬럼 (복합키의 leftColumn들)
const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || [];
console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns);
// 정렬 함수
const sortByKeys = (data: any[]) => {
if (sortColumns.length === 0) return data;
return [...data].sort((a, b) => {
for (const col of sortColumns) {
const aVal = String(a[col] || "");
const bVal = String(b[col] || "");
const cmp = aVal.localeCompare(bVal, "ko-KR");
if (cmp !== 0) return cmp;
}
return 0;
});
};
// 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우)
if (leftTable === rightTableName) {
const sortedData = sortByKeys(leftItem._originalItems);
console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length);
setRightData(sortedData);
return;
}
// 다른 테이블인 경우: 원본 ID들로 조회
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const allResults: any[] = [];
// 각 원본 항목에 대해 조회
for (const originalItem of leftItem._originalItems) {
const searchConditions: Record<string, any> = {};
keys?.forEach((key: any) => {
if (key.leftColumn && key.rightColumn && originalItem[key.leftColumn] !== undefined) {
searchConditions[key.rightColumn] = originalItem[key.leftColumn];
}
});
if (Object.keys(searchConditions).length > 0) {
const result = await entityJoinApi.getTableDataWithJoins(rightTableName, {
search: searchConditions,
enableEntityJoin: true,
size: 1000,
});
if (result.data) {
allResults.push(...result.data);
}
}
}
// 정렬 적용
const sortedResults = sortByKeys(allResults);
console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length);
setRightData(sortedResults);
return;
}
// 🆕 복합키 지원
if (keys && keys.length > 0 && leftTable) {
// 복합키: 여러 조건으로 필터링
@ -642,7 +908,28 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const uniqueValues = new Set<string>();
leftData.forEach((item) => {
const value = item[columnName];
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard)
let value: any;
if (columnName.includes(".")) {
// 조인 컬럼: getEntityJoinValue와 동일한 로직 적용
const [refTable, fieldName] = columnName.split(".");
const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
// 정확한 키로 먼저 시도
const exactKey = `${inferredSourceColumn}_${fieldName}`;
value = item[exactKey];
// 기본 별칭 패턴 시도 (item_code_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey];
}
} else {
// 일반 컬럼
value = item[columnName];
}
if (value !== null && value !== undefined && value !== "") {
// _name 필드 우선 사용 (category/entity type)
const displayValue = item[`${columnName}_name`] || value;
@ -666,6 +953,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const leftTableId = `split-panel-left-${component.id}`;
// 🔧 화면에 표시되는 컬럼 사용 (columns 속성)
const configuredColumns = componentConfig.leftPanel?.columns || [];
// 🆕 설정에서 지정한 라벨 맵 생성
const configuredLabels: Record<string, string> = {};
configuredColumns.forEach((col: any) => {
if (typeof col === "object" && col.name && col.label) {
configuredLabels[col.name] = col.label;
}
});
const displayColumns = configuredColumns
.map((col: any) => {
if (typeof col === "string") return col;
@ -683,7 +979,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
tableName: leftTableName,
columns: displayColumns.map((col: string) => ({
columnName: col,
columnLabel: leftColumnLabels[col] || col,
// 🆕 우선순위: 1) 설정에서 지정한 라벨 2) DB 라벨 3) 컬럼명
columnLabel: configuredLabels[col] || leftColumnLabels[col] || col,
inputType: "text",
visible: true,
width: 150,
@ -695,6 +992,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
onColumnVisibilityChange: setLeftColumnVisibility,
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백
});
return () => unregisterTable(leftTableId);
@ -1651,16 +1949,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
) : (
(() => {
// 🆕 그룹별 합산된 데이터 사용
const dataSource = summedLeftData;
console.log(
"🔍 [테이블모드 렌더링] dataSource 개수:",
dataSource.length,
"leftGroupSumConfig:",
leftGroupSumConfig,
);
// 🔧 로컬 검색 필터 적용
const filteredData = leftSearchQuery
? leftData.filter((item) => {
? dataSource.filter((item) => {
const searchLower = leftSearchQuery.toLowerCase();
return Object.entries(item).some(([key, value]) => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchLower);
});
})
: leftData;
: dataSource;
// 🔧 가시성 처리된 컬럼 사용
const columnsToShow =
@ -1737,7 +2044,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
{formatCellValue(
col.name,
item[col.name],
getEntityJoinValue(item, col.name),
leftCategoryMappings,
col.format,
)}
@ -1796,7 +2103,12 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{formatCellValue(col.name, item[col.name], leftCategoryMappings, col.format)}
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
leftCategoryMappings,
col.format,
)}
</td>
))}
</tr>
@ -1851,16 +2163,25 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div>
) : (
(() => {
// 🆕 그룹별 합산된 데이터 사용
const dataToDisplay = summedLeftData;
console.log(
"🔍 [렌더링] dataToDisplay 개수:",
dataToDisplay.length,
"leftGroupSumConfig:",
leftGroupSumConfig,
);
// 검색 필터링 (클라이언트 사이드)
const filteredLeftData = leftSearchQuery
? leftData.filter((item) => {
? dataToDisplay.filter((item) => {
const searchLower = leftSearchQuery.toLowerCase();
return Object.entries(item).some(([key, value]) => {
if (value === null || value === undefined) return false;
return String(value).toLowerCase().includes(searchLower);
});
})
: leftData;
: dataToDisplay;
// 재귀 렌더링 함수
const renderTreeItem = (item: any, index: number): React.ReactNode => {
@ -2108,23 +2429,53 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
if (isTableMode) {
// 테이블 모드 렌더링
const displayColumns = componentConfig.rightPanel?.columns || [];
const columnsToShow =
displayColumns.length > 0
? displayColumns.map((col) => ({
...col,
label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format, // 🆕 포맷 설정 유지
}))
: Object.keys(filteredData[0] || {})
.filter((key) => shouldShowField(key))
.slice(0, 5)
.map((key) => ({
name: key,
label: rightColumnLabels[key] || key,
width: 150,
align: "left" as const,
format: undefined, // 🆕 기본값
}));
// 🆕 그룹 합산 모드일 때: 복합키 컬럼을 우선 표시
const relationKeys = componentConfig.rightPanel?.relation?.keys || [];
const keyColumns = relationKeys.map((k: any) => k.leftColumn).filter(Boolean);
const isGroupedMode = selectedLeftItem?._originalItems?.length > 0;
let columnsToShow: any[] = [];
if (displayColumns.length > 0) {
// 설정된 컬럼 사용
columnsToShow = displayColumns.map((col) => ({
...col,
label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format,
}));
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
if (isGroupedMode && keyColumns.length > 0) {
const existingColNames = columnsToShow.map((c) => c.name);
const missingKeyColumns = keyColumns.filter((k: string) => !existingColNames.includes(k));
if (missingKeyColumns.length > 0) {
const keyColsToAdd = missingKeyColumns.map((colName: string) => ({
name: colName,
label: rightColumnLabels[colName] || colName,
width: 120,
align: "left" as const,
format: undefined,
_isKeyColumn: true, // 구분용 플래그
}));
columnsToShow = [...keyColsToAdd, ...columnsToShow];
console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns);
}
}
} else {
// 기본 컬럼 자동 생성
columnsToShow = Object.keys(filteredData[0] || {})
.filter((key) => shouldShowField(key))
.slice(0, 5)
.map((key) => ({
name: key,
label: rightColumnLabels[key] || key,
width: 150,
align: "left" as const,
format: undefined,
}));
}
return (
<div className="w-full">
@ -2150,11 +2501,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{col.label}
</th>
))}
{!isDesignMode && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
)}
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */}
{!isDesignMode &&
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
</th>
)}
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
@ -2169,43 +2523,51 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
style={{ textAlign: col.align || "left" }}
>
{formatCellValue(col.name, item[col.name], rightCategoryMappings, col.format)}
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{!isDesignMode && (
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
<div className="flex justify-end gap-1">
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button
variant={
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="h-7"
>
<Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
</div>
</td>
)}
{/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */}
{!isDesignMode &&
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
<td className="px-3 py-2 text-right text-sm whitespace-nowrap">
<div className="flex justify-end gap-1">
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
<Button
variant={
componentConfig.rightPanel?.editButton?.buttonVariant || "outline"
}
size="sm"
onClick={(e) => {
e.stopPropagation();
handleEditClick("right", item);
}}
className="h-7"
>
<Pencil className="mr-1 h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
</Button>
)}
{(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item);
}}
className="rounded p-1 transition-colors hover:bg-red-100"
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
>
<Trash2 className="h-4 w-4 text-red-600" />
</button>
)}
</div>
</td>
)}
</tr>
);
})}
@ -2240,78 +2602,16 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
firstValues = rightColumns
.slice(0, summaryCount)
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_number → item_number 또는 item_id_name)
let value = item[col.name];
if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split(".").pop();
// 1차: 컬럼명 그대로 (예: item_number)
value = item[columnName || ""];
// 2차: item_info.item_number → item_id_name 또는 item_id_item_number 형식 확인
if (value === undefined) {
const parts = col.name.split(".");
if (parts.length === 2) {
const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
// 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼)
if (
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
) {
// 기본 참조 컬럼 (item_number, customer_code 등)
const aliasKey = fkColumn + "_name";
value = item[aliasKey];
} else {
// 추가 컬럼 (item_name, customer_name 등)
const aliasKey = `${fkColumn}_${refColumn}`;
value = item[aliasKey];
}
}
}
}
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
const value = getEntityJoinValue(item, col.name);
return [col.name, value, col.label] as [string, any, string];
})
.filter(([_, value]) => value !== null && value !== undefined && value !== "");
allValues = rightColumns
.map((col) => {
// 🆕 엔티티 조인 컬럼 처리
let value = item[col.name];
if (value === undefined && col.name.includes(".")) {
const columnName = col.name.split(".").pop();
// 1차: 컬럼명 그대로
value = item[columnName || ""];
// 2차: {fk_column}_name 또는 {fk_column}_{ref_column} 형식 확인
if (value === undefined) {
const parts = col.name.split(".");
if (parts.length === 2) {
const refTable = parts[0]; // item_info
const refColumn = parts[1]; // item_number 또는 item_name
// FK 컬럼명 추론: item_info → item_id
const fkColumn = refTable.replace("_info", "").replace("_mng", "") + "_id";
// 백엔드에서 반환하는 별칭 패턴:
// 1) item_id_name (기본 referenceColumn)
// 2) item_id_item_name (추가 컬럼)
if (
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_number" ||
refColumn === refTable.replace("_info", "").replace("_mng", "") + "_code"
) {
// 기본 참조 컬럼
const aliasKey = fkColumn + "_name";
value = item[aliasKey];
} else {
// 추가 컬럼
const aliasKey = `${fkColumn}_${refColumn}`;
value = item[aliasKey];
}
}
}
}
// 🆕 엔티티 조인 컬럼 처리 (getEntityJoinValue 사용)
const value = getEntityJoinValue(item, col.name);
return [col.name, value, col.label] as [string, any, string];
})
.filter(([_, value]) => value !== null && value !== undefined && value !== "");

View File

@ -42,9 +42,9 @@
"@react-three/fiber": "^9.4.0",
"@tanstack/react-query": "^5.86.0",
"@tanstack/react-table": "^8.21.3",
"@tiptap/core": "^3.13.0",
"@tiptap/core": "^2.27.1",
"@tiptap/extension-placeholder": "^2.27.1",
"@tiptap/pm": "^2.11.5",
"@tiptap/pm": "^2.27.1",
"@tiptap/react": "^2.27.1",
"@tiptap/starter-kit": "^2.27.1",
"@turf/buffer": "^7.2.0",

View File

@ -7,21 +7,21 @@
*/
export interface TableFilter {
columnName: string;
operator:
| "equals"
| "contains"
| "startsWith"
| "endsWith"
| "gt"
| "lt"
| "gte"
| "lte"
| "notEquals";
operator: "equals" | "contains" | "startsWith" | "endsWith" | "gt" | "lt" | "gte" | "lte" | "notEquals";
value: string | number | boolean;
filterType?: "text" | "number" | "date" | "select"; // 필터 입력 타입
width?: number; // 필터 입력 필드 너비 (px)
}
/**
*
*/
export interface GroupSumConfig {
enabled: boolean; // 그룹핑 활성화 여부
groupByColumn: string; // 그룹 기준 컬럼
groupByColumnLabel?: string; // 그룹 기준 컬럼 라벨 (UI 표시용)
}
/**
*
*/
@ -60,7 +60,8 @@ export interface TableRegistration {
onFilterChange: (filters: TableFilter[]) => void;
onGroupChange: (groups: string[]) => void;
onColumnVisibilityChange: (columns: ColumnVisibility[]) => void;
onGroupSumChange?: (config: GroupSumConfig | null) => void; // 🆕 그룹별 합산 설정 변경
// 데이터 조회 함수 (선택 타입 필터용)
getColumnUniqueValues?: (columnName: string) => Promise<Array<{ label: string; value: string }>>;
}
@ -77,4 +78,3 @@ export interface TableOptionsContextValue {
selectedTableId: string | null;
setSelectedTableId: (tableId: string | null) => void;
}