Merge origin/main into ksh - resolve conflicts

This commit is contained in:
SeongHyun Kim 2025-12-15 17:28:32 +09:00
commit 2f66fe1913
63 changed files with 7542 additions and 3396 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

@ -341,6 +341,64 @@ export const uploadFiles = async (
});
}
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const isRecordMode = req.body.isRecordMode === "true" || req.body.isRecordMode === true;
// 🔍 디버깅: 레코드 모드 조건 확인
console.log("🔍 [파일 업로드] 레코드 모드 조건 확인:", {
isRecordMode,
linkedTable,
recordId,
columnName,
finalTargetObjid,
"req.body.isRecordMode": req.body.isRecordMode,
"req.body.linkedTable": req.body.linkedTable,
"req.body.recordId": req.body.recordId,
"req.body.columnName": req.body.columnName,
});
if (isRecordMode && linkedTable && recordId && columnName) {
try {
// 해당 레코드의 모든 첨부파일 조회
const allFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[finalTargetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = allFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${linkedTable}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, companyCode]
);
console.log("📎 [레코드 모드] attachments 컬럼 자동 업데이트:", {
tableName: linkedTable,
recordId: recordId,
columnName: columnName,
fileCount: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 업로드는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
res.json({
success: true,
message: `${files.length}개 파일 업로드 완료`,
@ -405,6 +463,56 @@ export const deleteFile = async (
["DELETED", parseInt(objid)]
);
// 🆕 레코드 모드: 해당 행의 attachments 컬럼 자동 업데이트
const targetObjid = fileRecord.target_objid;
if (targetObjid && !targetObjid.startsWith('screen_files:') && !targetObjid.startsWith('temp_')) {
// targetObjid 파싱: tableName:recordId:columnName 형식
const parts = targetObjid.split(':');
if (parts.length >= 3) {
const [tableName, recordId, columnName] = parts;
try {
// 해당 레코드의 남은 첨부파일 조회
const remainingFiles = await query<any>(
`SELECT objid, real_file_name, file_size, file_ext, file_path, regdate
FROM attach_file_info
WHERE target_objid = $1 AND status = 'ACTIVE'
ORDER BY regdate DESC`,
[targetObjid]
);
// attachments JSONB 형태로 변환
const attachmentsJson = remainingFiles.map((f: any) => ({
objid: f.objid.toString(),
realFileName: f.real_file_name,
fileSize: Number(f.file_size),
fileExt: f.file_ext,
filePath: f.file_path,
regdate: f.regdate?.toISOString(),
}));
// 해당 테이블의 attachments 컬럼 업데이트
// 🔒 멀티테넌시: company_code 필터 추가
await query(
`UPDATE ${tableName}
SET ${columnName} = $1::jsonb, updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[JSON.stringify(attachmentsJson), recordId, fileRecord.company_code]
);
console.log("📎 [파일 삭제] attachments 컬럼 자동 업데이트:", {
tableName,
recordId,
columnName,
remainingFiles: attachmentsJson.length,
});
} catch (updateError) {
// attachments 컬럼 업데이트 실패해도 파일 삭제는 성공으로 처리
console.warn("⚠️ attachments 컬럼 업데이트 실패 (무시):", updateError);
}
}
}
res.json({
success: true,
message: "파일이 삭제되었습니다.",

View File

@ -50,3 +50,4 @@ router.get("/data/:groupCode", getAutoFillData);
export default router;

View File

@ -46,3 +46,4 @@ router.get("/filtered-options/:relationCode", getFilteredOptions);
export default router;

View File

@ -62,3 +62,4 @@ router.get("/:groupCode/options/:levelOrder", getLevelOptions);
export default router;

View File

@ -50,3 +50,4 @@ router.get("/options/:exclusionCode", getExcludedOptions);
export default router;

View File

@ -19,15 +19,21 @@ export class AdminService {
// menuType에 따른 WHERE 조건 생성
const menuTypeCondition =
menuType !== undefined ? `MENU.MENU_TYPE = ${parseInt(menuType)}` : "1 = 1";
menuType !== undefined
? `MENU.MENU_TYPE = ${parseInt(menuType)}`
: "1 = 1";
// 메뉴 관리 화면인지 좌측 사이드바인지 구분
// includeInactive가 true거나 menuType이 undefined면 메뉴 관리 화면
const includeInactive = paramMap.includeInactive === true;
const isManagementScreen = includeInactive || menuType === undefined;
// 메뉴 관리 화면: 모든 상태의 메뉴 표시, 좌측 사이드바: active만 표시
const statusCondition = isManagementScreen ? "1 = 1" : "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen ? "1 = 1" : "MENU_SUB.STATUS = 'active'";
const statusCondition = isManagementScreen
? "1 = 1"
: "MENU.STATUS = 'active'";
const subStatusCondition = isManagementScreen
? "1 = 1"
: "MENU_SUB.STATUS = 'active'";
// 1. 권한 그룹 기반 필터링 (좌측 사이드바인 경우만)
let authFilter = "";
@ -35,7 +41,11 @@ export class AdminService {
let queryParams: any[] = [userLang];
let paramIndex = 2;
if (menuType !== undefined && userType !== "SUPER_ADMIN" && !isManagementScreen) {
if (
menuType !== undefined &&
userType !== "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN이 아닌 경우만 권한 체크
const userRoleGroups = await query<any>(
`
@ -56,45 +66,45 @@ export class AdminService {
);
if (userType === "COMPANY_ADMIN") {
// 회사 관리자: 자기 회사 메뉴는 모두, 공통 메뉴는 권한 있는 것만
// 회사 관리자: 권한 그룹 기반 필터링 적용
if (userRoleGroups.length > 0) {
const roleObjids = userRoleGroups.map((rg: any) => rg.role_objid);
// 루트 메뉴: 회사 코드만 체크 (권한 체크 X)
authFilter = `AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')`;
// 회사 관리자도 권한 그룹 설정에 따라 메뉴 필터링
authFilter = `
AND MENU.COMPANY_CODE IN ($${paramIndex}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU.OBJID
AND rma.auth_objid = ANY($${paramIndex + 1})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(userCompanyCode);
const companyParamIndex = paramIndex;
paramIndex++;
// 하위 메뉴: 회사 메뉴는 모두, 공통 메뉴는 권한 체크
// 하위 메뉴 권한 체크
unionFilter = `
AND (
MENU_SUB.COMPANY_CODE = $${companyParamIndex}
OR (
MENU_SUB.COMPANY_CODE = '*'
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
)
AND MENU_SUB.COMPANY_CODE IN ($${paramIndex - 1}, '*')
AND EXISTS (
SELECT 1
FROM rel_menu_auth rma
WHERE rma.menu_objid = MENU_SUB.OBJID
AND rma.auth_objid = ANY($${paramIndex})
AND rma.read_yn = 'Y'
)
`;
queryParams.push(roleObjids);
paramIndex++;
logger.info(
`✅ 회사 관리자: 회사 ${userCompanyCode} 메뉴 전체 + 권한 있는 공통 메뉴`
`✅ 회사 관리자: 권한 있는 메뉴만 (${roleObjids.length}개 그룹)`
);
} else {
// 권한 그룹이 없는 회사 관리자: 자기 회사 메뉴만
authFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
logger.info(
`✅ 회사 관리자 (권한 그룹 없음): 회사 ${userCompanyCode} 메뉴만`
// 권한 그룹이 없는 회사 관리자: 메뉴 없음
logger.warn(
`⚠️ 회사 관리자 ${userId}는 권한 그룹이 없어 메뉴가 표시되지 않습니다.`
);
return [];
}
} else {
// 일반 사용자: 권한 그룹 필수
@ -131,7 +141,11 @@ export class AdminService {
return [];
}
}
} else if (menuType !== undefined && userType === "SUPER_ADMIN" && !isManagementScreen) {
} else if (
menuType !== undefined &&
userType === "SUPER_ADMIN" &&
!isManagementScreen
) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
@ -167,7 +181,7 @@ export class AdminService {
companyFilter = `AND MENU.COMPANY_CODE = $${paramIndex}`;
queryParams.push(userCompanyCode);
paramIndex++;
// 하위 메뉴에도 회사 필터링 적용 (공통 메뉴 제외)
if (unionFilter === "") {
unionFilter = `AND MENU_SUB.COMPANY_CODE = $${paramIndex - 1}`;

View File

@ -903,6 +903,9 @@ export class DynamicFormService {
return `${key} = $${index + 1}::numeric`;
} else if (dataType === "boolean") {
return `${key} = $${index + 1}::boolean`;
} else if (dataType === "jsonb" || dataType === "json") {
// 🆕 JSONB/JSON 타입은 명시적 캐스팅
return `${key} = $${index + 1}::jsonb`;
} else {
// 문자열 타입은 캐스팅 불필요
return `${key} = $${index + 1}`;
@ -910,7 +913,21 @@ export class DynamicFormService {
})
.join(", ");
const values: any[] = Object.values(changedFields);
// 🆕 JSONB 타입 값은 JSON 문자열로 변환
const values: any[] = Object.keys(changedFields).map((key) => {
const value = changedFields[key];
const dataType = columnTypes[key];
// JSONB/JSON 타입이고 배열/객체인 경우 JSON 문자열로 변환
if (
(dataType === "jsonb" || dataType === "json") &&
(Array.isArray(value) ||
(typeof value === "object" && value !== null))
) {
return JSON.stringify(value);
}
return value;
});
values.push(id); // WHERE 조건용 ID 추가
// 🔑 Primary Key 타입에 맞게 캐스팅
@ -1575,6 +1592,7 @@ export class DynamicFormService {
/**
* ( )
*
*/
private async executeDataflowControlIfConfigured(
screenId: number,
@ -1616,105 +1634,67 @@ export class DynamicFormService {
hasDataflowConfig: !!properties?.webTypeConfig?.dataflowConfig,
hasDiagramId:
!!properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId,
hasFlowControls:
!!properties?.webTypeConfig?.dataflowConfig?.flowControls,
});
// 버튼 컴포넌트이고 저장 액션이며 제어관리가 활성화된 경우
if (
properties?.componentType === "button-primary" &&
properties?.componentConfig?.action?.type === "save" &&
properties?.webTypeConfig?.enableDataflowControl === true &&
properties?.webTypeConfig?.dataflowConfig?.selectedDiagramId
properties?.webTypeConfig?.enableDataflowControl === true
) {
controlConfigFound = true;
const diagramId =
properties.webTypeConfig.dataflowConfig.selectedDiagramId;
const relationshipId =
properties.webTypeConfig.dataflowConfig.selectedRelationshipId;
const dataflowConfig = properties?.webTypeConfig?.dataflowConfig;
console.log(`🎯 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId,
relationshipId,
triggerType,
});
// 다중 제어 설정 확인 (flowControls 배열)
const flowControls = dataflowConfig?.flowControls || [];
// 노드 플로우 실행 (relationshipId가 없는 경우 노드 플로우로 간주)
let controlResult: any;
// flowControls가 있으면 다중 제어 실행, 없으면 기존 단일 제어 실행
if (flowControls.length > 0) {
controlConfigFound = true;
console.log(`🎯 다중 제어관리 설정 발견: ${flowControls.length}`);
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
// 순서대로 정렬
const sortedControls = [...flowControls].sort(
(a: any, b: any) => (a.order || 0) - (b.order || 0)
);
const executionResult = await NodeFlowExecutionService.executeFlow(
// 다중 제어 순차 실행
await this.executeMultipleFlowControls(
sortedControls,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
} else if (dataflowConfig?.selectedDiagramId) {
// 기존 단일 제어 실행 (하위 호환성)
controlConfigFound = true;
const diagramId = dataflowConfig.selectedDiagramId;
const relationshipId = dataflowConfig.selectedRelationshipId;
console.log(`🎯 단일 제어관리 설정 발견:`, {
componentId: layout.component_id,
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
relationshipId,
triggerType,
});
controlResult = {
success: executionResult.success,
message: executionResult.message,
executedActions: executionResult.nodes?.map((node) => ({
nodeId: node.nodeId,
status: node.status,
duration: node.duration,
})),
errors: executionResult.nodes
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
await this.executeSingleFlowControl(
diagramId,
relationshipId,
savedData,
screenId,
tableName,
triggerType,
userId,
companyCode
);
controlResult =
await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
// 오류가 있는 경우 경고 로그 출력 (성공이지만 일부 액션 실패)
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
// 오류 정보를 별도로 저장하여 필요시 사용자에게 알림 가능
// 현재는 로그만 출력하고 메인 저장 프로세스는 계속 진행
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
// 제어관리 실패는 메인 저장 프로세스에 영향을 주지 않음
}
// 첫 번째 설정된 제어관리만 실행 (여러 개가 있을 경우)
// 첫 번째 설정된 버튼의 제어관리만 실행
break;
}
}
@ -1728,6 +1708,218 @@ export class DynamicFormService {
}
}
/**
*
*/
private async executeMultipleFlowControls(
flowControls: Array<{
id: string;
flowId: number;
flowName: string;
executionTiming: string;
order: number;
}>,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
console.log(`🚀 다중 제어 순차 실행 시작: ${flowControls.length}`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const results: Array<{
order: number;
flowId: number;
flowName: string;
success: boolean;
message: string;
duration: number;
}> = [];
for (let i = 0; i < flowControls.length; i++) {
const control = flowControls[i];
const startTime = Date.now();
console.log(
`\n📍 [${i + 1}/${flowControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`
);
try {
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ 유효하지 않은 flowId, 스킵: ${control.flowId}`);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: "유효하지 않은 flowId",
duration: 0,
});
continue;
}
const executionResult = await NodeFlowExecutionService.executeFlow(
control.flowId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
const duration = Date.now() - startTime;
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: executionResult.success,
message: executionResult.message,
duration,
});
if (executionResult.success) {
console.log(
`✅ [${i + 1}/${flowControls.length}] 제어 성공: ${control.flowName} (${duration}ms)`
);
} else {
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실패: ${control.flowName} - ${executionResult.message}`
);
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn(`⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단`);
break;
}
} catch (error: any) {
const duration = Date.now() - startTime;
console.error(
`❌ [${i + 1}/${flowControls.length}] 제어 실행 오류: ${control.flowName}`,
error
);
results.push({
order: control.order,
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message || "실행 오류",
duration,
});
// 오류 발생 시 다음 제어 실행 중단
console.warn(`⚠️ 제어 실행 오류로 인해 나머지 제어 실행 중단`);
break;
}
}
// 실행 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
console.log(`\n📊 다중 제어 실행 완료:`, {
total: flowControls.length,
executed: results.length,
success: successCount,
failed: failCount,
totalDuration: `${totalDuration}ms`,
});
}
/**
* ( , )
*/
private async executeSingleFlowControl(
diagramId: number,
relationshipId: string | null,
savedData: Record<string, any>,
screenId: number,
tableName: string,
triggerType: "insert" | "update" | "delete",
userId: string,
companyCode: string
): Promise<void> {
let controlResult: any;
if (!relationshipId) {
// 노드 플로우 실행
console.log(`🚀 노드 플로우 실행 (flowId: ${diagramId})`);
const { NodeFlowExecutionService } = await import(
"./nodeFlowExecutionService"
);
const executionResult = await NodeFlowExecutionService.executeFlow(
diagramId,
{
sourceData: [savedData],
dataSourceType: "formData",
buttonId: "save-button",
screenId: screenId,
userId: userId,
companyCode: companyCode,
formData: savedData,
}
);
controlResult = {
success: executionResult.success,
message: executionResult.message,
executedActions: executionResult.nodes?.map((node) => ({
nodeId: node.nodeId,
status: node.status,
duration: node.duration,
})),
errors: executionResult.nodes
?.filter((node) => node.status === "failed")
.map((node) => node.error || "실행 실패"),
};
} else {
// 관계 기반 제어관리 실행
console.log(
`🎯 관계 기반 제어관리 실행 (relationshipId: ${relationshipId})`
);
controlResult = await this.dataflowControlService.executeDataflowControl(
diagramId,
relationshipId,
triggerType,
savedData,
tableName,
userId
);
}
console.log(`🎯 제어관리 실행 결과:`, controlResult);
if (controlResult.success) {
console.log(`✅ 제어관리 실행 성공: ${controlResult.message}`);
if (
controlResult.executedActions &&
controlResult.executedActions.length > 0
) {
console.log(`📊 실행된 액션들:`, controlResult.executedActions);
}
if (controlResult.errors && controlResult.errors.length > 0) {
console.warn(
`⚠️ 제어관리 실행 중 일부 오류 발생:`,
controlResult.errors
);
}
} else {
console.warn(`⚠️ 제어관리 실행 실패: ${controlResult.message}`);
}
}
/**
*
* ( )

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();
// 연결 풀이 닫힌 상태인지 확인
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

@ -582,3 +582,4 @@ const result = await executeNodeFlow(flowId, {

View File

@ -355,3 +355,4 @@
- [ ] 부모 화면에서 모달로 데이터가 전달되는가?
- [ ] 발송 버튼의 데이터 소스가 올바르게 설정되어 있는가?

View File

@ -21,6 +21,7 @@ import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext"; // 🆕 분할 패널 리사이즈
function ScreenViewPage() {
const params = useParams();
@ -307,10 +308,7 @@ function ScreenViewPage() {
return (
<ScreenPreviewProvider isPreviewMode={false}>
<TableOptionsProvider>
<div
ref={containerRef}
className="bg-background h-full w-full overflow-auto p-3"
>
<div ref={containerRef} className="bg-background h-full w-full overflow-auto p-3">
{/* 레이아웃 준비 중 로딩 표시 */}
{!layoutReady && (
<div className="from-muted to-muted/50 flex h-full w-full items-center justify-center bg-gradient-to-br">
@ -358,7 +356,6 @@ function ScreenViewPage() {
return isButton;
});
topLevelComponents.forEach((component) => {
const isButton =
(component.type === "component" &&
@ -799,7 +796,9 @@ function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<ScreenViewPage />
<SplitPanelProvider>
<ScreenViewPage />
</SplitPanelProvider>
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);

View File

@ -9,6 +9,7 @@ import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import { DualListBox } from "@/components/common/DualListBox";
import { MenuPermissionsTable } from "./MenuPermissionsTable";
import { useMenu } from "@/contexts/MenuContext";
interface RoleDetailManagementProps {
roleId: string;
@ -25,6 +26,7 @@ interface RoleDetailManagementProps {
export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
const { user: currentUser } = useAuth();
const router = useRouter();
const { refreshMenus } = useMenu();
const isSuperAdmin = currentUser?.companyCode === "*" && currentUser?.userType === "SUPER_ADMIN";
@ -178,6 +180,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("멤버가 성공적으로 저장되었습니다.");
loadMembers(); // 새로고침
// 사이드바 메뉴 새로고침 (현재 사용자가 영향받을 수 있음)
await refreshMenus();
} else {
alert(response.message || "멤버 저장에 실패했습니다.");
}
@ -187,7 +192,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingMembers(false);
}
}, [roleGroup, selectedUsers, loadMembers]);
}, [roleGroup, selectedUsers, loadMembers, refreshMenus]);
// 메뉴 권한 저장 핸들러
const handleSavePermissions = useCallback(async () => {
@ -200,6 +205,9 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
if (response.success) {
alert("메뉴 권한이 성공적으로 저장되었습니다.");
loadMenuPermissions(); // 새로고침
// 사이드바 메뉴 새로고침 (권한 변경 즉시 반영)
await refreshMenus();
} else {
alert(response.message || "메뉴 권한 저장에 실패했습니다.");
}
@ -209,7 +217,7 @@ export function RoleDetailManagement({ roleId }: RoleDetailManagementProps) {
} finally {
setIsSavingPermissions(false);
}
}, [roleGroup, menuPermissions, loadMenuPermissions]);
}, [roleGroup, menuPermissions, loadMenuPermissions, refreshMenus]);
if (isLoading) {
return (

View File

@ -390,9 +390,11 @@ export interface RowDetailPopupConfig {
// 추가 데이터 조회 설정
additionalQuery?: {
enabled: boolean;
queryMode?: "table" | "custom"; // 조회 모드: table(테이블 조회), custom(커스텀 쿼리)
tableName: string; // 조회할 테이블명 (예: vehicles)
matchColumn: string; // 매칭할 컬럼 (예: id)
sourceColumn?: string; // 클릭한 행에서 가져올 컬럼 (기본: matchColumn과 동일)
customQuery?: string; // 커스텀 쿼리 ({id}, {vehicle_number} 등 파라미터 사용)
// 팝업에 표시할 컬럼 목록 (비어있으면 전체 표시)
displayColumns?: DisplayColumnConfig[];
};

View File

@ -158,7 +158,7 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
checked={popupConfig.additionalQuery?.enabled || false}
onCheckedChange={(enabled) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery, enabled, tableName: "", matchColumn: "" },
additionalQuery: { ...popupConfig.additionalQuery, enabled, queryMode: "table", tableName: "", matchColumn: "" },
})
}
aria-label="추가 데이터 조회 활성화"
@ -167,116 +167,230 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
{popupConfig.additionalQuery?.enabled && (
<div className="space-y-2">
{/* 조회 모드 선택 */}
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
<Label className="text-xs"> </Label>
<Select
value={popupConfig.additionalQuery?.queryMode || "table"}
onValueChange={(value: "table" | "custom") =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
additionalQuery: { ...popupConfig.additionalQuery!, queryMode: value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="table"> </SelectItem>
<SelectItem value="custom"> </SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시할 컬럼 선택 (다중 선택 + 라벨 편집) */}
{/* 테이블 조회 모드 */}
{(popupConfig.additionalQuery?.queryMode || "table") === "table" && (
<>
<div>
<Label className="text-xs"></Label>
<Input
value={popupConfig.additionalQuery?.tableName || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, tableName: e.target.value },
})
}
placeholder="vehicles"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.matchColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, matchColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
</div>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="비워두면 매칭 컬럼과 동일"
className="mt-1 h-8 text-xs"
/>
</div>
</>
)}
{/* 커스텀 쿼리 모드 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<div>
<Label className="text-xs"> ( )</Label>
<Input
value={popupConfig.additionalQuery?.sourceColumn || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, sourceColumn: e.target.value },
})
}
placeholder="id"
className="mt-1 h-8 text-xs"
/>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</div>
<div>
<Label className="text-xs"> </Label>
<textarea
value={popupConfig.additionalQuery?.customQuery || ""}
onChange={(e) =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, customQuery: e.target.value },
})
}
placeholder={`SELECT
v.vehicle_number AS "차량번호",
ROUND(SUM(ts.loaded_distance_km)::NUMERIC, 2) AS "운행거리"
FROM vehicles v
LEFT JOIN transport_statistics ts ON v.id = ts.vehicle_id
WHERE v.id = {id}
GROUP BY v.id;`}
className="mt-1 h-32 w-full rounded-md border border-input bg-background px-3 py-2 text-xs font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-muted-foreground mt-1 text-xs">
{"{id}"}, {"{vehicle_number}"}
</p>
</div>
</>
)}
{/* 표시할 컬럼 선택 - 테이블 모드와 커스텀 쿼리 모드 분기 */}
<div>
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
{/* 테이블 모드: 기존 쿼리 결과에서 선택 */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="mt-1 h-8 w-full justify-between text-xs">
<span className="truncate">
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0
? `${popupConfig.additionalQuery?.displayColumns?.length}개 선택됨`
: "전체 표시 (클릭하여 선택)"}
</span>
<ChevronDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72 p-2" align="start">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium"> </span>
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
</Button>
</div>
<div className="max-h-48 space-y-1 overflow-y-auto">
{/* 쿼리 결과 컬럼 목록 */}
{queryResult?.columns.map((col) => {
const currentColumns = popupConfig.additionalQuery?.displayColumns || [];
const existingConfig = currentColumns.find((c) =>
typeof c === 'object' ? c.column === col : c === col
);
const isSelected = !!existingConfig;
return (
<div
key={col}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-muted"
onClick={() => {
const newColumns = isSelected
? currentColumns.filter((c) =>
typeof c === 'object' ? c.column !== col : c !== col
)
: [...currentColumns, { column: col, label: col } as DisplayColumnConfig];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Checkbox checked={isSelected} className="h-3 w-3" />
<span className="text-xs">{col}</span>
</div>
);
})}
{(!queryResult?.columns || queryResult.columns.length === 0) && (
<p className="text-muted-foreground py-2 text-center text-xs">
</p>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</>
)}
{/* 커스텀 쿼리 모드: 직접 입력 방식 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (
<>
<p className="text-muted-foreground mt-1 text-xs">
.
AS "라벨명" alias를 .
</p>
<div className="mt-2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 text-xs"
onClick={() => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || []), { column: "", label: "" }];
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<Plus className="h-3 w-3" />
()
</Button>
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs"
onClick={() =>
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: [] },
})
}
>
</Button>
)}
</div>
</PopoverContent>
</Popover>
<p className="text-muted-foreground mt-1 text-xs"> </p>
</>
)}
{/* 선택된 컬럼 라벨 편집 */}
{(popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
{/* 선택된 컬럼 라벨 편집 (테이블 모드) */}
{popupConfig.additionalQuery?.queryMode !== "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<div className="space-y-1.5">
@ -321,6 +435,63 @@ export function ListWidgetSection({ queryResult, config, onConfigChange }: ListW
</div>
</div>
)}
{/* 커스텀 쿼리 모드: 직접 입력 컬럼 편집 */}
{popupConfig.additionalQuery?.queryMode === "custom" && (popupConfig.additionalQuery?.displayColumns?.length || 0) > 0 && (
<div className="mt-3 space-y-2">
<Label className="text-xs"> </Label>
<p className="text-muted-foreground text-xs"> </p>
<div className="space-y-1.5">
{popupConfig.additionalQuery?.displayColumns?.map((colConfig, index) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return (
<div key={index} className="flex items-center gap-2">
<Input
value={column}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column: e.target.value, label: label || e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="컬럼명 (쿼리 결과)"
className="h-7 flex-1 text-xs"
/>
<Input
value={label}
onChange={(e) => {
const newColumns = [...(popupConfig.additionalQuery?.displayColumns || [])];
newColumns[index] = { column, label: e.target.value };
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
placeholder="표시 라벨"
className="h-7 flex-1 text-xs"
/>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => {
const newColumns = (popupConfig.additionalQuery?.displayColumns || []).filter(
(_, i) => i !== index
);
updatePopupConfig({
additionalQuery: { ...popupConfig.additionalQuery!, displayColumns: newColumns },
});
}}
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
)}

View File

@ -64,22 +64,35 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
let query = additionalQuery.customQuery;
// console.log("🔍 [ListWidget] 커스텀 쿼리 파라미터 치환 시작");
// console.log("🔍 [ListWidget] 클릭한 행 데이터:", row);
// console.log("🔍 [ListWidget] 행 컬럼 목록:", Object.keys(row));
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
@ -87,12 +100,43 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
console.error("커스텀 쿼리 실행 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (error) {
console.error("추가 데이터 로드 실패:", error);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
}
},
[config.rowDetailPopup],
@ -104,9 +148,19 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "datetime":
return new Date(value).toLocaleString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":
@ -190,22 +244,34 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
const label = typeof colConfig === 'object' ? colConfig.label : colConfig;
return { column, label };
})
.filter((item) => item.column in row);
.filter((item) => item.column in mergedData);
} else {
// 전체 컬럼
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional).map((key) => ({ column: key, label: key }));
} else {
basicFields = Object.keys(row).map((key) => ({ column: key, label: key }));
}
}
groups.push({
@ -220,8 +286,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({

View File

@ -2,7 +2,19 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import {
ArrowLeft,
Save,
Loader2,
Grid3x3,
Move,
Box,
Package,
Truck,
Check,
ParkingCircle,
RefreshCw,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -78,7 +90,7 @@ const DebouncedInput = ({
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(false);
if (onCommit && debounce === 0) {
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}
@ -545,150 +557,170 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 레이아웃 데이터 로드
const [layoutData, setLayoutData] = useState<{ layout: any; objects: any[] } | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
useEffect(() => {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
// 레이아웃 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
if (response.success && response.data) {
const { layout, objects } = response.data;
setLayoutData({ layout, objects }); // 레이아웃 데이터 저장
// 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) {
setSelectedDbConnection(layout.external_db_connection_id);
}
// 계층 구조 설정 로드
if (layout.hierarchy_config) {
try {
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
// 선택된 테이블 정보도 복원
const newSelectedTables: any = {
warehouse: config.warehouse?.tableName || "",
area: "",
location: "",
material: "",
};
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
} catch (e) {
console.error("계층 구조 설정 파싱 실패:", e);
}
}
// 객체 데이터 변환 (DB -> PlacedObject)
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level || 1,
parentKey: obj.parent_key,
externalKey: obj.external_key,
}));
setPlacedObjects(loadedObjects);
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
setNextObjectId(minId - 1);
setHasUnsavedChanges(false);
toast({
title: "레이아웃 불러오기 완료",
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
// 외부 DB 연결 ID 복원
if (layout.external_db_connection_id) {
setSelectedDbConnection(layout.external_db_connection_id);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
// 계층 구조 설정 로드
if (layout.hierarchy_config) {
try {
// hierarchy_config가 문자열이면 파싱, 객체면 그대로 사용
const config =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(config);
// 선택된 테이블 정보도 복원
const newSelectedTables: any = {
warehouse: config.warehouse?.tableName || "",
area: "",
location: "",
material: "",
};
if (config.levels && config.levels.length > 0) {
// 레벨 1 = Area
if (config.levels[0]?.tableName) {
newSelectedTables.area = config.levels[0].tableName;
}
// 레벨 2 = Location
if (config.levels[1]?.tableName) {
newSelectedTables.location = config.levels[1].tableName;
}
}
// 자재 테이블 정보
if (config.material?.tableName) {
newSelectedTables.material = config.material.tableName;
}
setSelectedTables(newSelectedTables);
} catch (e) {
console.error("계층 구조 설정 파싱 실패:", e);
}
}
// 객체 데이터 변환 (DB -> PlacedObject)
const loadedObjects: PlacedObject[] = (objects as unknown as DbObject[]).map((obj) => ({
id: obj.id,
type: obj.object_type,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: obj.color,
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level || 1,
parentKey: obj.parent_key,
externalKey: obj.external_key,
}));
setPlacedObjects(loadedObjects);
// 다음 임시 ID 설정 (기존 ID 중 최소값 - 1)
const minId = Math.min(...loadedObjects.map((o) => o.id), 0);
setNextObjectId(minId - 1);
setHasUnsavedChanges(false);
toast({
title: "레이아웃 불러오기 완료",
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
});
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
const dbConnectionId = layout.external_db_connection_id;
const hierarchyConfigParsed =
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
const materialTableName = hierarchyConfigParsed?.material?.tableName;
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" ||
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey,
);
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
setTimeout(() => {
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
}, 100);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
if (hasUnsavedChanges) {
const confirmed = window.confirm(
"저장되지 않은 변경사항이 있습니다. 새로고침하면 변경사항이 사라집니다. 계속하시겠습니까?",
);
if (!confirmed) return;
}
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거
}, [layoutId]);
// 외부 DB 연결 자동 선택 (externalDbConnections 로드 완료 시)
useEffect(() => {
@ -1052,7 +1084,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
};
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
const loadMaterialCountsForLocations = async (
locaKeys: string[],
dbConnectionId?: number,
materialTableName?: string,
) => {
const connectionId = dbConnectionId || selectedDbConnection;
const tableName = materialTableName || selectedTables.material;
if (!connectionId || locaKeys.length === 0) return;
@ -1060,7 +1096,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
console.log("📊 자재 개수 API 응답:", response);
if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
@ -1073,10 +1109,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
const materialCount = response.data?.find(
(mc: any) =>
mc.LOCAKEY === obj.locaKey ||
mc.location_key === obj.locaKey ||
mc.locakey === obj.locaKey
(mc: any) => mc.LOCAKEY === obj.locaKey || mc.location_key === obj.locaKey || mc.locakey === obj.locaKey,
);
if (materialCount) {
// count 또는 material_count 필드 사용
@ -1527,6 +1560,16 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<div className="flex items-center gap-2">
{hasUnsavedChanges && <span className="text-warning text-sm font-medium"> </span>}
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
{isSaving ? (
<>
@ -1620,27 +1663,20 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</Button>
</div>
<div className="flex gap-2">
<Select
value={selectedTemplateId}
onValueChange={(val) => setSelectedTemplateId(val)}
>
<Select value={selectedTemplateId} onValueChange={(val) => setSelectedTemplateId(val)}>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder={loadingTemplates ? "로딩 중..." : "템플릿 선택..."} />
</SelectTrigger>
<SelectContent>
{mappingTemplates.length === 0 ? (
<div className="text-muted-foreground px-2 py-1 text-xs">
릿
</div>
<div className="text-muted-foreground px-2 py-1 text-xs"> 릿 </div>
) : (
mappingTemplates.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id} className="text-xs">
<div className="flex flex-col">
<span>{tpl.name}</span>
{tpl.description && (
<span className="text-muted-foreground text-[10px]">
{tpl.description}
</span>
<span className="text-muted-foreground text-[10px]">{tpl.description}</span>
)}
</div>
</SelectItem>
@ -1704,17 +1740,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
}}
onLoadColumns={async (tableName: string) => {
try {
const response = await ExternalDbConnectionAPI.getTableColumns(
selectedDbConnection,
tableName,
);
const response = await ExternalDbConnectionAPI.getTableColumns(selectedDbConnection, tableName);
if (response.success && response.data) {
// 컬럼 정보 객체 배열로 반환 (이름 + 설명 + PK 플래그)
return response.data.map((col: any) => ({
column_name:
typeof col === "string"
? col
: col.column_name || col.COLUMN_NAME || String(col),
column_name: typeof col === "string" ? col : col.column_name || col.COLUMN_NAME || String(col),
data_type: col.data_type || col.DATA_TYPE,
description: col.description || col.COLUMN_COMMENT || undefined,
is_primary_key: col.is_primary_key ?? col.IS_PRIMARY_KEY,
@ -2354,10 +2384,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
>
</Button>
<Button
onClick={handleSaveTemplate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
<Button onClick={handleSaveTemplate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>

View File

@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@ -41,130 +41,144 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// 검색 및 필터
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState<string>("all");
const [isRefreshing, setIsRefreshing] = useState(false);
// 레이아웃 데이터 로드
useEffect(() => {
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
// 레이아웃 데이터 로드 함수
const loadLayout = async () => {
try {
setIsLoading(true);
const response = await getLayoutById(layoutId);
if (response.success && response.data) {
const { layout, objects } = response.data;
if (response.success && response.data) {
const { layout, objects } = response.data;
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
setLayoutName(layout.layout_name || layout.layoutName);
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
setExternalDbConnectionId(dbConnectionId);
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string"
? JSON.parse(layout.hierarchy_config)
: layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// hierarchy_config 저장
let hierarchyConfigData: any = null;
if (layout.hierarchy_config) {
hierarchyConfigData =
typeof layout.hierarchy_config === "string" ? JSON.parse(layout.hierarchy_config) : layout.hierarchy_config;
setHierarchyConfig(hierarchyConfigData);
}
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
// 객체 데이터 변환
const loadedObjects: PlacedObject[] = objects.map((obj: any) => {
const objectType = obj.object_type;
return {
id: obj.id,
type: objectType,
name: obj.object_name,
position: {
x: parseFloat(obj.position_x),
y: parseFloat(obj.position_y),
z: parseFloat(obj.position_z),
},
size: {
x: parseFloat(obj.size_x),
y: parseFloat(obj.size_y),
z: parseFloat(obj.size_z),
},
rotation: obj.rotation ? parseFloat(obj.rotation) : 0,
color: getObjectColor(objectType, obj.color), // 저장된 색상 우선, 없으면 타입별 기본 색상
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview:
obj.loc_type === "STP" || !obj.material_preview_height
? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
visible: obj.visible !== false,
hierarchyLevel: obj.hierarchy_level,
parentKey: obj.parent_key,
externalKey: obj.external_key,
};
});
setPlacedObjects(loadedObjects);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey,
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
}
return { id: obj.id, count: 0 };
});
setPlacedObjects(loadedObjects);
const materialCounts = await Promise.all(materialCountPromises);
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
if (dbConnectionId && hierarchyConfigData?.material) {
const locationObjects = loadedObjects.filter(
(obj) =>
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey
);
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
const materialCountPromises = locationObjects.map(async (obj) => {
try {
const matResponse = await getMaterials(dbConnectionId, {
tableName: hierarchyConfigData.material.tableName,
keyColumn: hierarchyConfigData.material.keyColumn,
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
layerColumn: hierarchyConfigData.material.layerColumn,
locaKey: obj.locaKey!,
});
if (matResponse.success && matResponse.data) {
return { id: obj.id, count: matResponse.data.length };
}
} catch (e) {
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return { id: obj.id, count: 0 };
});
const materialCounts = await Promise.all(materialCountPromises);
// materialCount 업데이트
setPlacedObjects((prev) =>
prev.map((obj) => {
const countData = materialCounts.find((m) => m.id === obj.id);
if (countData && countData.count > 0) {
return { ...obj, materialCount: countData.count };
}
return obj;
})
);
}
} else {
throw new Error(response.error || "레이아웃 조회 실패");
return obj;
}),
);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
} else {
throw new Error(response.error || "레이아웃 조회 실패");
}
};
} catch (error) {
console.error("레이아웃 로드 실패:", error);
const errorMessage = error instanceof Error ? error.message : "레이아웃을 불러오는데 실패했습니다.";
toast({
variant: "destructive",
title: "오류",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
// 위젯 새로고침 핸들러
const handleRefresh = async () => {
setIsRefreshing(true);
setSelectedObject(null);
setMaterials([]);
setShowInfoPanel(false);
await loadLayout();
setIsRefreshing(false);
toast({
title: "새로고침 완료",
description: "데이터가 갱신되었습니다.",
});
};
// 초기 로드
useEffect(() => {
loadLayout();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutId]); // toast 제거 - 무한 루프 방지
}, [layoutId]);
// Location의 자재 목록 로드
const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => {
@ -322,6 +336,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
<h2 className="text-lg font-semibold">{layoutName || "디지털 트윈 야드"}</h2>
<p className="text-muted-foreground text-sm"> </p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing || isLoading}
title="새로고침"
>
<RefreshCw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
{isRefreshing ? "갱신 중..." : "새로고침"}
</Button>
</div>
{/* 메인 영역 */}
@ -404,59 +428,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) {
return (
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
<div className="space-y-2">
{filteredObjects.map((obj) => {
let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙";
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
return (
<div
key={obj.id}
onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }}
/>
<span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
);
})}
</div>
);
})}
</div>
);
}
// Area가 있는 경우: Area → Location 계층 아코디언
@ -525,8 +549,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
/>
</div>
<p className="text-muted-foreground mt-1 text-[10px]">
: ({locationObj.position.x.toFixed(1)},{" "}
{locationObj.position.z.toFixed(1)})
: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
</p>
{locationObj.locaKey && (
<p className="text-muted-foreground mt-0.5 text-[10px]">

View File

@ -1,13 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@ -183,15 +177,66 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
// 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
// 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
const parentData =
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
// 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
// parentDataMapping에서 명시된 필드만 추출
const parentDataMapping = splitPanelContext?.parentDataMapping || [];
// 부모 데이터 소스
const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
: splitPanelContext?.getMappedParentData() || {};
: splitPanelContext?.selectedLeftData || {};
// 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
const parentData: Record<string, any> = {};
// 필수 연결 필드: company_code (멀티테넌시)
if (rawParentData.company_code) {
parentData.company_code = rawParentData.company_code;
}
// parentDataMapping에 정의된 필드만 전달
for (const mapping of parentDataMapping) {
const sourceValue = rawParentData[mapping.sourceColumn];
if (sourceValue !== undefined && sourceValue !== null) {
parentData[mapping.targetColumn] = sourceValue;
console.log(
`🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn}${mapping.targetColumn} = ${sourceValue}`,
);
}
}
// parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
if (parentDataMapping.length === 0) {
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
];
for (const [key, value] of Object.entries(rawParentData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
parentData[key] = value;
console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
}
}
}
if (Object.keys(parentData).length > 0) {
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
setFormData(parentData);
} else {
setFormData({});
@ -604,19 +649,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">
{modalState.description}
</DialogDescription>
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">
{loading ? "화면을 불러오는 중입니다..." : ""}
</DialogDescription>
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex-1 overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">

View File

@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
if (additionalQuery?.enabled) {
const queryMode = additionalQuery.queryMode || "table";
// 커스텀 쿼리 모드
if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
// 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
let query = additionalQuery.customQuery;
// console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
// console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
// console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
Object.keys(row).forEach((key) => {
const value = row[key];
const placeholder = new RegExp(`\\{${key}\\}`, "g");
// SQL 인젝션 방지를 위해 값 이스케이프
const safeValue = typeof value === "string"
? value.replace(/'/g, "''")
: value;
query = query.replace(placeholder, String(safeValue ?? ""));
// console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
});
// console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
// console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
console.error("커스텀 쿼리 실행 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
// 테이블 조회 모드
else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
const matchValue = row[sourceColumn];
if (matchValue !== undefined && matchValue !== null) {
setDetailPopupLoading(true);
try {
const query = `
SELECT *
FROM ${additionalQuery.tableName}
WHERE ${additionalQuery.matchColumn} = '${matchValue}'
LIMIT 1;
`;
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
} else {
setAdditionalDetailData({});
}
} catch (err) {
console.error("추가 데이터 로드 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
}
}
},
[config.rowDetailPopup],
@ -136,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
switch (format) {
case "date":
return new Date(value).toLocaleDateString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "datetime":
return new Date(value).toLocaleString("ko-KR");
try {
const dateVal = new Date(value);
return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
} catch {
return String(value);
}
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":
@ -222,13 +276,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const getDefaultFieldGroups = (row: Record<string, any>, additional: Record<string, any> | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
// 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
// row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
? { ...row, ...additional } // additional이 row를 덮어씀
: row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
// 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
@ -237,8 +299,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})
.filter((item) => allKeys.includes(item.column));
} else {
// 전체 컬럼
basicFields = allKeys.map((key) => ({ column: key, label: key }));
// 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
basicFields = Object.keys(additional)
.filter((key) => !key.startsWith("_"))
.map((key) => ({ column: key, label: key }));
} else {
basicFields = allKeys.map((key) => ({ column: key, label: key }));
}
}
groups.push({
@ -253,8 +321,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})),
});
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
if (additional && Object.keys(additional).length > 0) {
// 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({

View File

@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier);
try {
// user_id 또는 vehicle_number로 조회
// user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
last_trip_start,
last_trip_end,
last_trip_distance, last_trip_time,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id = '${identifier}'
@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return;
try {
// 모든 마커의 운행/공차 정보를 한 번에 조회
// 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT
id, vehicle_number, user_id,
last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
last_trip_start,
last_trip_end,
last_trip_distance, last_trip_time,
last_empty_start,
last_empty_end,
last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})

View File

@ -109,7 +109,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
@ -264,8 +264,8 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : (editData || {}));
setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
}
@ -298,7 +298,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
loadGroupData();
@ -436,7 +436,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
@ -445,7 +445,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
@ -470,13 +470,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
const normalizeDateField = (value: any): string | null => {
if (!value) return null;
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
if (value instanceof Date || typeof value === "string") {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return null;
// YYYY-MM-DD 형식으로 변환
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@ -487,7 +487,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return null;
}
}
return null;
};
@ -508,7 +508,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const insertData: Record<string, any> = { ...currentData };
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
@ -592,9 +592,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
for (const currentData of groupData) {
if (currentData.id) {
// id 기반 매칭 (인덱스 기반 X)
const originalItemData = originalGroupData.find(
(orig) => orig.id === currentData.id
);
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
if (!originalItemData) {
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
@ -604,13 +602,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 🆕 값 정규화 함수 (타입 통일)
const normalizeValue = (val: any, fieldName?: string): any => {
if (val === null || val === undefined || val === "") return null;
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
if (fieldName && dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(val);
return normalizedDate;
}
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
@ -667,9 +665,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupData.filter(
(orig) => orig.id && !currentIds.has(orig.id)
);
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log("🗑️ 품목 삭제:", deletedItem);
@ -677,7 +673,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
try {
const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id,
screenData.screenInfo.tableName
screenData.screenInfo.tableName,
);
if (response.success) {
@ -760,11 +756,11 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// originalData가 비어있으면 INSERT, 있으면 UPDATE
const isCreateMode = Object.keys(originalData).length === 0;
if (isCreateMode) {
// INSERT 모드
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId!,
tableName: screenData.screenInfo.tableName,
@ -908,12 +904,13 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4
const extraPadding = 24; // 추가 여백 (안전 마진)
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding;
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
return {
className: "overflow-hidden p-0",
@ -930,10 +927,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent
className={`${modalStyle.className} ${className || ""} max-w-none`}
style={modalStyle.style}
>
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
@ -946,7 +940,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-track]:bg-transparent">
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -959,7 +953,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
@ -969,25 +963,41 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
},
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
};
// 🔍 디버깅: enrichedFormData 확인
console.log("🔑 [EditModal] enrichedFormData 생성:", {
"screenData.screenInfo": screenData.screenInfo,
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
"enrichedFormData.tableName": enrichedFormData.tableName,
"enrichedFormData.id": enrichedFormData.id,
});
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={groupData.length > 0 ? groupData[0] : formData}
formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
@ -1000,14 +1010,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
prev.map((item) => ({
...item,
[fieldName]: value,
}))
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{

View File

@ -50,6 +50,7 @@ import { cn } from "@/lib/utils";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableOptionsToolbar } from "./table-options/TableOptionsToolbar";
import { SplitPanelProvider } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
/**
* 🔗
@ -2101,113 +2102,115 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
: component;
return (
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<SplitPanelProvider>
<TableOptionsProvider>
<div className="flex h-full flex-col">
{/* 테이블 옵션 툴바 */}
<TableOptionsToolbar />
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-background border rounded" style={{
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
minHeight: "400px",
position: "relative",
overflow: "hidden"
}}>
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
{popupLayout.map((popupComponent) => (
<div
key={popupComponent.id}
className="absolute"
style={{
left: `${popupComponent.position.x}px`,
top: `${popupComponent.position.y}px`,
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(fieldName, value) => {
console.log("💾 팝업 formData 업데이트:", {
fieldName,
value,
valueType: typeof value,
prevFormData: popupFormData
});
setPopupFormData(prev => ({
...prev,
[fieldName]: value
}));
}}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> .</div>
</div>
{/* 메인 컨텐츠 */}
<div className="h-full flex-1" style={{ width: '100%' }}>
{/* 라벨이 있는 경우 표시 (데이터 테이블 제외) */}
{shouldShowLabel && (
<label className="mb-2 block text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{labelText}
{(component.required || component.componentConfig?.required) && <span className="ml-1 text-destructive">*</span>}
</label>
)}
{/* 실제 위젯 - 상위에서 라벨을 렌더링했으므로 자식은 라벨 숨김 */}
<div className="h-full" style={{ width: '100%', height: '100%' }}>{renderInteractiveWidget(componentForRendering)}</div>
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</div>
{/* 개선된 검증 패널 (선택적 표시) */}
{showValidationPanel && enhancedValidation && (
<div className="absolute bottom-4 right-4 z-50">
<FormValidationIndicator
validationState={enhancedValidation.validationState}
saveState={enhancedValidation.saveState}
onSave={async () => {
const success = await enhancedValidation.saveForm();
if (success) {
toast.success("데이터가 성공적으로 저장되었습니다!");
}
}}
canSave={enhancedValidation.canSave}
compact={true}
showDetails={false}
/>
</div>
)}
{/* 모달 화면 */}
<Dialog open={!!popupScreen} onOpenChange={() => {
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> ...</div>
</div>
) : popupLayout.length > 0 ? (
<div className="relative bg-background border rounded" style={{
width: popupScreenResolution ? `${popupScreenResolution.width}px` : "100%",
height: popupScreenResolution ? `${popupScreenResolution.height}px` : "400px",
minHeight: "400px",
position: "relative",
overflow: "hidden"
}}>
{/* 팝업에서도 실제 위치와 크기로 렌더링 */}
{popupLayout.map((popupComponent) => (
<div
key={popupComponent.id}
className="absolute"
style={{
left: `${popupComponent.position.x}px`,
top: `${popupComponent.position.y}px`,
width: popupComponent.style?.width || `${popupComponent.size.width}px`,
height: popupComponent.style?.height || `${popupComponent.size.height}px`,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
<InteractiveScreenViewer
component={popupComponent}
allComponents={popupLayout}
hideLabel={false}
screenInfo={popupScreenInfo || undefined}
formData={popupFormData}
onFormDataChange={(fieldName, value) => {
console.log("💾 팝업 formData 업데이트:", {
fieldName,
value,
valueType: typeof value,
prevFormData: popupFormData
});
setPopupFormData(prev => ({
...prev,
[fieldName]: value
}));
}}
/>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground"> .</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
</TableOptionsProvider>
</SplitPanelProvider>
);
};

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentData, WebType, isWidgetComponent, isContainerComponent } from "@/types";
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
import { Input } from "@/components/ui/input";
@ -14,6 +14,7 @@ import { FileUpload } from "./widgets/FileUpload";
import { useAuth } from "@/hooks/useAuth";
import { DynamicWebTypeRenderer, WebTypeRegistry } from "@/lib/registry";
import { DataTableTemplate } from "@/components/screen/templates/DataTableTemplate";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
import {
Database,
Type,
@ -110,8 +111,8 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
};
// 동적 웹 타입 위젯 렌더링 컴포넌트
const WidgetRenderer: React.FC<{
component: ComponentData;
const WidgetRenderer: React.FC<{
component: ComponentData;
isDesignMode?: boolean;
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -253,22 +254,23 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 플로우 위젯의 실제 높이 측정
useEffect(() => {
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isFlowWidget =
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
if (isFlowWidget && contentRef.current) {
const measureHeight = () => {
if (contentRef.current) {
// getBoundingClientRect()로 실제 렌더링된 높이 측정
const rect = contentRef.current.getBoundingClientRect();
const measured = rect.height;
// scrollHeight도 함께 확인하여 더 큰 값 사용
const scrollHeight = contentRef.current.scrollHeight;
const rawHeight = Math.max(measured, scrollHeight);
// 40px 단위로 올림
const finalHeight = Math.ceil(rawHeight / 40) * 40;
if (finalHeight > 0 && Math.abs(finalHeight - (actualHeight || 0)) > 10) {
setActualHeight(finalHeight);
}
@ -400,12 +402,118 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}, [component.id, fileUpdateTrigger]);
// 컴포넌트 스타일 계산
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isFlowWidget =
type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
const positionX = position?.x || 0;
const positionY = position?.y || 0;
// 🆕 분할 패널 리사이즈 Context
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
const componentType = (component as any).componentType || "";
const componentId = (component as any).componentId || "";
const widgetType = (component as any).widgetType || "";
const isButtonComponent =
(type === "widget" && widgetType === "button") ||
(type === "component" &&
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
// 디버깅: 모든 컴포넌트의 타입 정보 출력 (버튼 관련만)
if (componentType.includes("button") || componentId.includes("button") || widgetType.includes("button")) {
console.log("🔘 [RealtimePreview] 버튼 컴포넌트 발견:", {
id: component.id,
type,
componentType,
componentId,
widgetType,
isButtonComponent,
positionX,
positionY,
});
}
// 🆕 분할 패널 위 버튼 위치 자동 조정
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = useMemo(() => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
const isSplitPanelComponent =
type === "component" &&
["split-panel-layout", "split-panel-layout2"].includes((component as any).componentType || "");
if (!isButtonComponent || isSplitPanelComponent) {
return { adjustedPositionX: positionX, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const componentWidth = size?.width || 100;
const componentHeight = size?.height || 40;
// 분할 패널 위에 있는지 확인
const overlap = getOverlappingSplitPanel(positionX, positionY, componentWidth, componentHeight);
// 디버깅: 버튼이 분할 패널 위에 있는지 확인
if (isButtonComponent) {
console.log("🔍 [RealtimePreview] 버튼 분할 패널 감지:", {
componentId: component.id,
componentType: (component as any).componentType,
positionX,
positionY,
componentWidth,
componentHeight,
hasOverlap: !!overlap,
isInLeftPanel: overlap?.isInLeftPanel,
panelInfo: overlap
? {
panelId: overlap.panelId,
panelX: overlap.panel.x,
panelY: overlap.panel.y,
panelWidth: overlap.panel.width,
leftWidthPercent: overlap.panel.leftWidthPercent,
initialLeftWidthPercent: overlap.panel.initialLeftWidthPercent,
}
: null,
});
}
if (!overlap || !overlap.isInLeftPanel) {
// 분할 패널 위에 없거나 우측 패널 위에 있음
return {
adjustedPositionX: positionX,
isOnSplitPanel: !!overlap,
isDraggingSplitPanel: overlap?.panel.isDragging ?? false,
};
}
// 좌측 패널 위에 있음 - 위치 조정
const adjusted = getAdjustedX(positionX, positionY, componentWidth, componentHeight);
console.log("✅ [RealtimePreview] 버튼 위치 조정 적용:", {
componentId: component.id,
originalX: positionX,
adjustedX: adjusted,
delta: adjusted - positionX,
});
return {
adjustedPositionX: adjusted,
isOnSplitPanel: true,
isDraggingSplitPanel: overlap.panel.isDragging,
};
}, [
positionX,
positionY,
size?.width,
size?.height,
isButtonComponent,
type,
component,
getAdjustedX,
getOverlappingSplitPanel,
]);
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => {
// 1순위: style.width가 있으면 우선 사용 (퍼센트 값)
@ -437,23 +545,27 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
const componentStyle = {
position: "absolute" as const,
...style, // 먼저 적용하고
left: positionX,
left: adjustedPositionX, // 🆕 분할 패널 위 버튼은 조정된 X 좌표 사용
top: positionY,
width: getWidth(), // 우선순위에 따른 너비
height: getHeight(), // 우선순위에 따른 높이
zIndex: position?.z || 1,
// right 속성 강제 제거
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
};
// 선택된 컴포넌트 스타일
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
const selectionStyle = isSelected && !isSectionPaper
? {
outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px",
}
: {};
const selectionStyle =
isSelected && !isSectionPaper
? {
outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px",
}
: {};
const handleClick = (e: React.MouseEvent) => {
// 컴포넌트 영역 내에서만 클릭 이벤트 처리
@ -481,10 +593,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragEnd={handleDragEnd}
>
{/* 컴포넌트 타입별 렌더링 */}
<div
ref={isFlowWidget ? contentRef : undefined}
className="h-full w-full"
>
<div ref={isFlowWidget ? contentRef : undefined} className="h-full w-full">
{/* 영역 타입 */}
{type === "area" && renderArea(component, children)}
@ -549,16 +658,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
return (
<div className="h-auto w-full">
<FlowWidget
component={flowComponent as any}
onSelectedDataChange={onFlowSelectedDataChange}
/>
<FlowWidget component={flowComponent as any} onSelectedDataChange={onFlowSelectedDataChange} />
</div>
);
})()}
{/* 탭 컴포넌트 타입 */}
{(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) &&
{(type === "tabs" ||
(type === "component" &&
((component as any).componentType === "tabs-widget" ||
(component as any).componentId === "tabs-widget"))) &&
(() => {
console.log("🎯 탭 컴포넌트 조건 충족:", {
type,
@ -590,9 +699,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
<Badge key={tab.id} variant="outline" className="text-xs">
{tab.label || `${index + 1}`}
{tab.screenName && (
<span className="ml-1 text-[10px] text-gray-400">
({tab.screenName})
</span>
<span className="ml-1 text-[10px] text-gray-400">({tab.screenName})</span>
)}
</Badge>
))}
@ -632,28 +739,29 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
)}
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
{type === "component" && (() => {
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return (
<DynamicComponentRenderer
component={component}
isSelected={isSelected}
isDesignMode={isDesignMode}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...restProps}
>
{children}
</DynamicComponentRenderer>
);
})()}
{type === "component" &&
(() => {
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
return (
<DynamicComponentRenderer
component={component}
isSelected={isSelected}
isDesignMode={isDesignMode}
onClick={onClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
{...restProps}
>
{children}
</DynamicComponentRenderer>
);
})()}
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
<div className="h-full w-full">
<WidgetRenderer
component={component}
<WidgetRenderer
component={component}
isDesignMode={isDesignMode}
sortBy={sortBy}
sortOrder={sortOrder}

View File

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useMemo } from "react";
import { ComponentData, WebType, WidgetComponent } from "@/types/screen";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import {
@ -16,6 +16,7 @@ import {
Building,
File,
} from "lucide-react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
// 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components";
@ -60,7 +61,7 @@ interface RealtimePreviewProps {
sortBy?: string;
sortOrder?: "asc" | "desc";
columnOrder?: string[];
// 🆕 조건부 컨테이너 높이 변화 콜백
onHeightChange?: (componentId: string, newHeight: number) => void;
}
@ -262,14 +263,145 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}
: component;
// 🆕 분할 패널 리사이즈 Context
const splitPanelContext = useSplitPanel();
// 버튼 컴포넌트인지 확인 (분할 패널 위치 조정 대상)
const componentType = (component as any).componentType || "";
const componentId = (component as any).componentId || "";
const widgetType = (component as any).widgetType || "";
const isButtonComponent =
(type === "widget" && widgetType === "button") ||
(type === "component" &&
(["button-primary", "button-secondary"].includes(componentType) ||
["button-primary", "button-secondary"].includes(componentId)));
// 🆕 버튼이 처음 렌더링될 때의 분할 패널 정보를 기억 (기준점)
const initialPanelRatioRef = React.useRef<number | null>(null);
const initialPanelIdRef = React.useRef<string | null>(null);
// 버튼이 좌측 패널에 속하는지 여부 (한번 설정되면 유지)
const isInLeftPanelRef = React.useRef<boolean | null>(null);
// 🆕 분할 패널 위 버튼 위치 자동 조정
const calculateButtonPosition = () => {
// 버튼이 아니거나 분할 패널 컴포넌트 자체인 경우 조정하지 않음
const isSplitPanelComponent =
type === "component" && ["split-panel-layout", "split-panel-layout2"].includes(componentType);
if (!isButtonComponent || isSplitPanelComponent) {
return { adjustedPositionX: position.x, isOnSplitPanel: false, isDraggingSplitPanel: false };
}
const componentWidth = size?.width || 100;
const componentHeight = size?.height || 40;
// 분할 패널 위에 있는지 확인 (원래 위치 기준)
const overlap = splitPanelContext.getOverlappingSplitPanel(position.x, position.y, componentWidth, componentHeight);
// 분할 패널 위에 없으면 기준점 초기화
if (!overlap) {
if (initialPanelIdRef.current !== null) {
initialPanelRatioRef.current = null;
initialPanelIdRef.current = null;
isInLeftPanelRef.current = null;
}
return {
adjustedPositionX: position.x,
isOnSplitPanel: false,
isDraggingSplitPanel: false,
};
}
const { panel } = overlap;
// 🆕 초기 기준 비율 및 좌측 패널 소속 여부 설정 (처음 한 번만)
if (initialPanelIdRef.current !== overlap.panelId) {
initialPanelRatioRef.current = panel.leftWidthPercent;
initialPanelIdRef.current = overlap.panelId;
// 초기 배치 시 좌측 패널에 있는지 확인 (초기 비율 기준으로 계산)
// 현재 비율이 아닌, 버튼 원래 위치가 초기 좌측 패널 영역 안에 있었는지 판단
const initialLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
const componentCenterX = position.x + componentWidth / 2;
const relativeX = componentCenterX - panel.x;
const wasInLeftPanel = relativeX < initialLeftPanelWidth;
isInLeftPanelRef.current = wasInLeftPanel;
console.log("📌 [버튼 기준점 설정]:", {
componentId: component.id,
panelId: overlap.panelId,
initialRatio: panel.leftWidthPercent,
isInLeftPanel: wasInLeftPanel,
buttonCenterX: componentCenterX,
leftPanelWidth: initialLeftPanelWidth,
});
}
// 좌측 패널 소속이 아니면 조정하지 않음 (초기 배치 기준)
if (!isInLeftPanelRef.current) {
return {
adjustedPositionX: position.x,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
}
// 초기 기준 비율 (버튼이 처음 배치될 때의 비율)
const baseRatio = initialPanelRatioRef.current ?? panel.leftWidthPercent;
// 기준 비율 대비 현재 비율로 분할선 위치 계산
const baseDividerX = panel.x + (panel.width * baseRatio) / 100; // 초기 분할선 위치
const currentDividerX = panel.x + (panel.width * panel.leftWidthPercent) / 100; // 현재 분할선 위치
// 분할선 이동량 (px)
const dividerDelta = currentDividerX - baseDividerX;
// 변화가 없으면 원래 위치 반환
if (Math.abs(dividerDelta) < 1) {
return {
adjustedPositionX: position.x,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
}
// 🆕 버튼도 분할선과 같은 양만큼 이동
// 분할선이 왼쪽으로 100px 이동하면, 버튼도 왼쪽으로 100px 이동
const adjustedX = position.x + dividerDelta;
console.log("📍 [버튼 위치 조정]:", {
componentId: component.id,
originalX: position.x,
adjustedX,
dividerDelta,
baseRatio,
currentRatio: panel.leftWidthPercent,
baseDividerX,
currentDividerX,
isDragging: panel.isDragging,
});
return {
adjustedPositionX: adjustedX,
isOnSplitPanel: true,
isDraggingSplitPanel: panel.isDragging,
};
};
const { adjustedPositionX, isOnSplitPanel, isDraggingSplitPanel } = calculateButtonPosition();
const baseStyle = {
left: `${position.x}px`,
left: `${adjustedPositionX}px`, // 🆕 조정된 X 좌표 사용
top: `${position.y}px`,
...componentStyle, // componentStyle 전체 적용 (DynamicComponentRenderer에서 이미 size가 변환됨)
width: getWidth(), // getWidth() 우선 (table-list 등 특수 케이스)
height: getHeight(), // getHeight() 우선 (flow-widget 등 특수 케이스)
zIndex: component.type === "layout" ? 1 : position.z || 2,
right: undefined,
// 🆕 분할 패널 드래그 중에는 트랜지션 없이 즉시 이동
transition:
isOnSplitPanel && isButtonComponent ? (isDraggingSplitPanel ? "none" : "left 0.1s ease-out") : undefined,
};
// 크기 정보는 필요시에만 디버깅 (개발 중 문제 발생 시 주석 해제)

View File

@ -0,0 +1,92 @@
"use client";
import React, { useMemo } from "react";
import { useSplitPanel } from "@/lib/registry/components/split-panel-layout/SplitPanelContext";
interface SplitPanelAwareWrapperProps {
children: React.ReactNode;
componentX: number;
componentY: number;
componentWidth: number;
componentHeight: number;
componentType?: string;
style?: React.CSSProperties;
className?: string;
}
/**
*
*
* :
* 1.
* 2. , X
* 3.
*/
export const SplitPanelAwareWrapper: React.FC<SplitPanelAwareWrapperProps> = ({
children,
componentX,
componentY,
componentWidth,
componentHeight,
componentType,
style,
className,
}) => {
const { getAdjustedX, getOverlappingSplitPanel } = useSplitPanel();
// 분할 패널 위에 있는지 확인 및 조정된 X 좌표 계산
const { adjustedX, isInLeftPanel, isDragging } = useMemo(() => {
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
if (!overlap) {
// 분할 패널 위에 없음
return { adjustedX: componentX, isInLeftPanel: false, isDragging: false };
}
if (!overlap.isInLeftPanel) {
// 우측 패널 위에 있음 - 원래 위치 유지
return { adjustedX: componentX, isInLeftPanel: false, isDragging: overlap.panel.isDragging };
}
// 좌측 패널 위에 있음 - 위치 조정
const adjusted = getAdjustedX(componentX, componentY, componentWidth, componentHeight);
return {
adjustedX: adjusted,
isInLeftPanel: true,
isDragging: overlap.panel.isDragging,
};
}, [componentX, componentY, componentWidth, componentHeight, getAdjustedX, getOverlappingSplitPanel]);
// 조정된 스타일
const adjustedStyle: React.CSSProperties = {
...style,
position: "absolute",
left: `${adjustedX}px`,
top: `${componentY}px`,
width: componentWidth,
height: componentHeight,
// 드래그 중에는 트랜지션 없이 즉시 이동, 드래그 끝나면 부드럽게
transition: isDragging ? "none" : "left 0.1s ease-out",
};
// 디버그 로깅 (개발 중에만)
// if (isInLeftPanel) {
// console.log(`📍 [SplitPanelAwareWrapper] 위치 조정:`, {
// componentType,
// originalX: componentX,
// adjustedX,
// delta: adjustedX - componentX,
// isInLeftPanel,
// isDragging,
// });
// }
return (
<div style={adjustedStyle} className={className}>
{children}
</div>
);
};
export default SplitPanelAwareWrapper;

View File

@ -1,11 +1,14 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
import { Settings, Clock, Info, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Settings, Clock, Info, Workflow, Plus, Trash2, GripVertical, ChevronUp, ChevronDown } from "lucide-react";
import { ComponentData } from "@/types/screen";
import { getNodeFlows, NodeFlow } from "@/lib/api/nodeFlows";
@ -14,11 +17,22 @@ interface ImprovedButtonControlConfigPanelProps {
onUpdateProperty: (path: string, value: any) => void;
}
// 다중 제어 설정 인터페이스
interface FlowControlConfig {
id: string;
flowId: number;
flowName: string;
executionTiming: "before" | "after" | "replace";
order: number;
}
/**
* 🔥
* 🔥
*
* :
* -
* :
* -
* -
* -
*/
export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlConfigPanelProps> = ({
component,
@ -27,6 +41,9 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
const config = component.webTypeConfig || {};
const dataflowConfig = config.dataflowConfig || {};
// 다중 제어 설정 (배열)
const flowControls: FlowControlConfig[] = dataflowConfig.flowControls || [];
// 🔥 State 관리
const [flows, setFlows] = useState<NodeFlow[]>([]);
const [loading, setLoading] = useState(false);
@ -58,24 +75,118 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
};
/**
* 🔥
* 🔥
*/
const handleFlowSelect = (flowId: string) => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) {
// 전체 dataflowConfig 업데이트 (selectedDiagramId 포함)
onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig,
selectedDiagramId: selectedFlow.flowId, // 백엔드에서 사용
selectedRelationshipId: null, // 노드 플로우는 관계 ID 불필요
flowConfig: {
flowId: selectedFlow.flowId,
flowName: selectedFlow.flowName,
executionTiming: "before", // 기본값
contextData: {},
},
});
}
const handleAddControl = useCallback(() => {
const newControl: FlowControlConfig = {
id: `control_${Date.now()}`,
flowId: 0,
flowName: "",
executionTiming: "after",
order: flowControls.length + 1,
};
const updatedControls = [...flowControls, newControl];
updateFlowControls(updatedControls);
}, [flowControls]);
/**
* 🔥
*/
const handleRemoveControl = useCallback(
(controlId: string) => {
const updatedControls = flowControls
.filter((c) => c.id !== controlId)
.map((c, index) => ({ ...c, order: index + 1 }));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleFlowSelect = useCallback(
(controlId: string, flowId: string) => {
const selectedFlow = flows.find((f) => f.flowId.toString() === flowId);
if (selectedFlow) {
const updatedControls = flowControls.map((c) =>
c.id === controlId ? { ...c, flowId: selectedFlow.flowId, flowName: selectedFlow.flowName } : c,
);
updateFlowControls(updatedControls);
}
},
[flows, flowControls],
);
/**
* 🔥
*/
const handleTimingChange = useCallback(
(controlId: string, timing: "before" | "after" | "replace") => {
const updatedControls = flowControls.map((c) => (c.id === controlId ? { ...c, executionTiming: timing } : c));
updateFlowControls(updatedControls);
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveUp = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index > 0) {
const updatedControls = [...flowControls];
[updatedControls[index - 1], updatedControls[index]] = [updatedControls[index], updatedControls[index - 1]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥
*/
const handleMoveDown = useCallback(
(controlId: string) => {
const index = flowControls.findIndex((c) => c.id === controlId);
if (index < flowControls.length - 1) {
const updatedControls = [...flowControls];
[updatedControls[index], updatedControls[index + 1]] = [updatedControls[index + 1], updatedControls[index]];
// 순서 번호 재정렬
updatedControls.forEach((c, i) => (c.order = i + 1));
updateFlowControls(updatedControls);
}
},
[flowControls],
);
/**
* 🔥 ( )
*/
const updateFlowControls = (controls: FlowControlConfig[]) => {
// 첫 번째 제어를 기존 형식으로도 저장 (하위 호환성)
const firstValidControl = controls.find((c) => c.flowId > 0);
onUpdateProperty("webTypeConfig.dataflowConfig", {
...dataflowConfig,
// 기존 형식 (하위 호환성)
selectedDiagramId: firstValidControl?.flowId || null,
selectedRelationshipId: null,
flowConfig: firstValidControl
? {
flowId: firstValidControl.flowId,
flowName: firstValidControl.flowName,
executionTiming: firstValidControl.executionTiming,
contextData: {},
}
: null,
// 새로운 다중 제어 형식
flowControls: controls,
});
};
return (
@ -98,32 +209,57 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
{config.enableDataflowControl && (
<div className="space-y-4">
<FlowSelector
flows={flows}
selectedFlowId={dataflowConfig.flowConfig?.flowId}
onSelect={handleFlowSelect}
loading={loading}
/>
{/* 제어 목록 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4 text-green-600" />
<Label> ( )</Label>
</div>
<Button variant="outline" size="sm" onClick={handleAddControl} className="h-8">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{dataflowConfig.flowConfig && (
<div className="space-y-4">
<Separator />
<ExecutionTimingSelector
value={dataflowConfig.flowConfig.executionTiming}
onChange={(timing) =>
onUpdateProperty("webTypeConfig.dataflowConfig.flowConfig.executionTiming", timing)
}
/>
{/* 제어 목록 */}
{flowControls.length === 0 ? (
<div className="rounded-md border border-dashed p-6 text-center">
<Workflow className="mx-auto h-8 w-8 text-gray-400" />
<p className="mt-2 text-sm text-gray-500"> </p>
<Button variant="outline" size="sm" onClick={handleAddControl} className="mt-3">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
) : (
<div className="space-y-2">
{flowControls.map((control, index) => (
<FlowControlItem
key={control.id}
control={control}
flows={flows}
loading={loading}
isFirst={index === 0}
isLast={index === flowControls.length - 1}
onFlowSelect={(flowId) => handleFlowSelect(control.id, flowId)}
onTimingChange={(timing) => handleTimingChange(control.id, timing)}
onMoveUp={() => handleMoveUp(control.id)}
onMoveDown={() => handleMoveDown(control.id)}
onRemove={() => handleRemoveControl(control.id)}
/>
))}
</div>
)}
<div className="rounded bg-green-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-green-600" />
<div className="text-xs text-green-800">
<p className="font-medium"> :</p>
<p className="mt-1"> / .</p>
<p className="mt-1"> 트랜잭션: /</p>
<p> 중단: 부모 </p>
</div>
{/* 안내 메시지 */}
{flowControls.length > 0 && (
<div className="rounded bg-blue-50 p-3">
<div className="flex items-start space-x-2">
<Info className="mt-0.5 h-4 w-4 text-blue-600" />
<div className="text-xs text-blue-800">
<p className="font-medium"> :</p>
<p className="mt-1"> </p>
<p> </p>
<p> </p>
</div>
</div>
</div>
@ -135,90 +271,89 @@ export const ImprovedButtonControlConfigPanel: React.FC<ImprovedButtonControlCon
};
/**
* 🔥
* 🔥
*/
const FlowSelector: React.FC<{
const FlowControlItem: React.FC<{
control: FlowControlConfig;
flows: NodeFlow[];
selectedFlowId?: number;
onSelect: (flowId: string) => void;
loading: boolean;
}> = ({ flows, selectedFlowId, onSelect, loading }) => {
isFirst: boolean;
isLast: boolean;
onFlowSelect: (flowId: string) => void;
onTimingChange: (timing: "before" | "after" | "replace") => void;
onMoveUp: () => void;
onMoveDown: () => void;
onRemove: () => void;
}> = ({ control, flows, loading, isFirst, isLast, onFlowSelect, onTimingChange, onMoveUp, onMoveDown, onRemove }) => {
return (
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Workflow className="h-4 w-4 text-green-600" />
<Label> </Label>
</div>
<Card className="p-3">
<div className="flex items-start gap-2">
{/* 순서 표시 및 이동 버튼 */}
<div className="flex flex-col items-center gap-1">
<Badge variant="secondary" className="h-6 w-6 justify-center rounded-full p-0 text-xs">
{control.order}
</Badge>
<div className="flex flex-col">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveUp} disabled={isFirst}>
<ChevronUp className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onMoveDown} disabled={isLast}>
<ChevronDown className="h-3 w-3" />
</Button>
</div>
</div>
<Select value={selectedFlowId?.toString() || ""} onValueChange={onSelect}>
<SelectTrigger>
<SelectValue placeholder="플로우를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="p-4 text-center text-sm text-gray-500"> ...</div>
) : flows.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-500">
<p> </p>
<p className="mt-2 text-xs"> </p>
</div>
) : (
flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<div className="flex flex-col">
<span className="font-medium">{flow.flowName}</span>
{flow.flowDescription && (
<span className="text-muted-foreground text-xs">{flow.flowDescription}</span>
)}
</div>
{/* 플로우 선택 및 설정 */}
<div className="flex-1 space-y-2">
{/* 플로우 선택 */}
<Select value={control.flowId > 0 ? control.flowId.toString() : ""} onValueChange={onFlowSelect}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="플로우를 선택하세요" />
</SelectTrigger>
<SelectContent>
{loading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : flows.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
flows.map((flow) => (
<SelectItem key={flow.flowId} value={flow.flowId.toString()}>
<span className="text-xs">{flow.flowName}</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
{/* 실행 타이밍 */}
<Select value={control.executionTiming} onValueChange={onTimingChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<span className="text-xs">Before ( )</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
);
};
<SelectItem value="after">
<span className="text-xs">After ( )</span>
</SelectItem>
<SelectItem value="replace">
<span className="text-xs">Replace ( )</span>
</SelectItem>
</SelectContent>
</Select>
</div>
/**
* 🔥
*/
const ExecutionTimingSelector: React.FC<{
value: string;
onChange: (timing: "before" | "after" | "replace") => void;
}> = ({ value, onChange }) => {
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-orange-600" />
<Label> </Label>
{/* 삭제 버튼 */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:bg-red-50 hover:text-red-600"
onClick={onRemove}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Select value={value} onValueChange={onChange}>
<SelectTrigger>
<SelectValue placeholder="실행 타이밍을 선택하세요" />
</SelectTrigger>
<SelectContent>
<SelectItem value="before">
<div className="flex flex-col">
<span className="font-medium">Before ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="after">
<div className="flex flex-col">
<span className="font-medium">After ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
<SelectItem value="replace">
<div className="flex flex-col">
<span className="font-medium">Replace ( )</span>
<span className="text-muted-foreground text-xs"> </span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
);
};

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

@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
import {
RepeaterFieldGroupConfig,
RepeaterData,
RepeaterItemData,
RepeaterFieldDefinition,
CalculationFormula,
} from "@/types/repeater";
import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -46,7 +52,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const breakpoint = previewBreakpoint || globalBreakpoint;
// 카테고리 매핑 데이터 (값 -> {label, color})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
const [categoryMappings, setCategoryMappings] = useState<
Record<string, Record<string, { label: string; color: string }>>
>({});
// 설정 기본값
const {
@ -78,10 +86,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 접힌 상태 관리 (각 항목별)
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
const initialCalcDoneRef = useRef(false);
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
const deletedItemIdsRef = useRef<string[]>([]);
@ -98,47 +106,60 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => {
if (value.length > 0) {
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter(f => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map(item => {
const updatedItem = { ...item };
let hasChange = false;
calculatedFields.forEach(calcField => {
const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue;
hasChange = true;
}
});
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
if (updatedItem.id) {
updatedItem._existingRecord = true;
}
return hasChange ? updatedItem : item;
});
setItems(updatedValue);
initialCalcDoneRef.current = true;
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
const dataWithMeta = config.targetTable
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
: updatedValue;
onChange?.(dataWithMeta);
// 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음)
if (value.length === 0) {
// minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화
if (minItems > 0) {
const emptyItems = Array(minItems)
.fill(null)
.map(() => createEmptyItem());
setItems(emptyItems);
} else {
// 🆕 기존 레코드 플래그 추가
const valueWithFlag = value.map(item => ({
...item,
_existingRecord: !!item.id,
}));
setItems(valueWithFlag);
setItems([]);
}
initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행
return;
}
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter((f) => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map((item) => {
const updatedItem = { ...item };
let hasChange = false;
calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue;
hasChange = true;
}
});
// 🆕 기존 레코드임을 표시 (id가 있는 경우)
if (updatedItem.id) {
updatedItem._existingRecord = true;
}
return hasChange ? updatedItem : item;
});
setItems(updatedValue);
initialCalcDoneRef.current = true;
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
const dataWithMeta = config.targetTable
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
: updatedValue;
onChange?.(dataWithMeta);
} else {
// 🆕 기존 레코드 플래그 추가
const valueWithFlag = value.map((item) => ({
...item,
_existingRecord: !!item.id,
}));
setItems(valueWithFlag);
}
}, [value]);
@ -164,14 +185,14 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length <= minItems) {
return;
}
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
const removedItem = items[index];
if (removedItem?.id) {
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
}
const newItems = items.filter((_, i) => i !== index);
setItems(newItems);
@ -179,10 +200,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
const currentDeletedIds = deletedItemIdsRef.current;
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
const dataWithMeta = config.targetTable
? newItems.map((item, idx) => ({
...item,
? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
@ -205,16 +226,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
...newItems[itemIndex],
[fieldName]: value,
};
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
const calculatedFields = fields.filter(f => f.type === "calculated");
calculatedFields.forEach(calcField => {
const calculatedFields = fields.filter((f) => f.type === "calculated");
calculatedFields.forEach((calcField) => {
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
if (calculatedValue !== null) {
newItems[itemIndex][calcField.name] = calculatedValue;
}
});
setItems(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex,
@ -227,8 +248,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 🆕 삭제된 항목 ID 목록도 유지
const currentDeletedIds = deletedItemIdsRef.current;
const dataWithMeta = config.targetTable
? newItems.map((item, idx) => ({
...item,
? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
@ -288,14 +309,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
*/
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
if (!formula || !formula.field1) return null;
const value1 = parseFloat(item[formula.field1]) || 0;
const value2 = formula.field2
? (parseFloat(item[formula.field2]) || 0)
: (formula.constantValue ?? 0);
const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0);
let result: number;
switch (formula.operator) {
case "+":
result = value1 + value2;
@ -331,7 +350,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
default:
result = value1;
}
return result;
};
@ -341,42 +360,44 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
* @param format
* @returns
*/
const formatNumber = (
value: number | null,
format?: RepeaterFieldDefinition["numberFormat"]
): string => {
const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => {
if (value === null || isNaN(value)) return "-";
let formattedValue = value;
// 소수점 자릿수 적용
if (format?.decimalPlaces !== undefined) {
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
}
// 천 단위 구분자
let result = format?.useThousandSeparator !== false
? formattedValue.toLocaleString("ko-KR", {
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
})
: formattedValue.toString();
let result =
format?.useThousandSeparator !== false
? formattedValue.toLocaleString("ko-KR", {
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
})
: formattedValue.toString();
// 접두사/접미사 추가
if (format?.prefix) result = format.prefix + result;
if (format?.suffix) result = result + format.suffix;
return result;
};
// 개별 필드 렌더링
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
// 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성
// "id(를) 입력하세요" 같은 잘못된 기본값 방지
const defaultPlaceholder = field.placeholder || `${field.label || field.name}`;
const commonProps = {
value: value || "",
disabled: isReadonly,
placeholder: field.placeholder,
placeholder: defaultPlaceholder,
required: field.required,
};
@ -385,25 +406,21 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
const item = items[itemIndex];
const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return (
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
{formattedValue}
</span>
);
return <span className="inline-block min-w-[80px] text-sm font-medium text-blue-700">{formattedValue}</span>;
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
if (field.type === "category") {
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
// field.name을 키로 사용 (테이블 리스트와 동일)
const mapping = categoryMappings[field.name];
const valueStr = String(value); // 값을 문자열로 변환
const categoryData = mapping?.[valueStr];
const displayLabel = categoryData?.label || valueStr;
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
fieldName: field.name,
value: valueStr,
@ -412,12 +429,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
displayLabel,
displayColor,
});
// 색상이 "none"이면 일반 텍스트로 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
@ -436,10 +453,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (field.displayMode === "readonly") {
// select 타입인 경우 옵션에서 라벨 찾기
if (field.type === "select" && value && field.options) {
const option = field.options.find(opt => opt.value === value);
const option = field.options.find((opt) => opt.value === value);
return <span className="text-sm">{option?.label || value}</span>;
}
// 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드)
const mapping = categoryMappings[field.name];
if (mapping && value) {
@ -461,16 +478,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
);
}
// 색상이 없으면 텍스트로 표시
return <span className="text-sm text-foreground">{categoryData.label}</span>;
return <span className="text-foreground text-sm">{categoryData.label}</span>;
}
}
// 일반 텍스트
return (
<span className="text-sm text-foreground">
{value || "-"}
</span>
);
return <span className="text-foreground text-sm">{value || "-"}</span>;
}
switch (field.type) {
@ -500,35 +513,46 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3}
className="resize-none min-w-[100px]"
className="min-w-[100px] resize-none"
/>
);
case "date":
case "date": {
// 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환
let dateValue = value || "";
if (dateValue && typeof dateValue === "string") {
// ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 날짜 부분만 추출
if (dateValue.includes("T")) {
dateValue = dateValue.split("T")[0];
}
// 유효한 날짜인지 확인
const parsedDate = new Date(dateValue);
if (isNaN(parsedDate.getTime())) {
dateValue = ""; // 유효하지 않은 날짜면 빈 값
}
}
return (
<Input
{...commonProps}
value={dateValue}
type="date"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)}
className="min-w-[120px]"
/>
);
}
case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
const numValue = parseFloat(value) || 0;
const formattedDisplay = formatNumber(numValue, field.numberFormat);
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return (
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
return <span className="inline-block min-w-[80px] text-sm">{formattedDisplay}</span>;
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
return (
<div className="relative min-w-[80px]">
@ -540,15 +564,11 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
max={field.validation?.max}
className="pr-1"
/>
{value && (
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
{value && <div className="text-muted-foreground mt-0.5 text-[10px]">{formattedDisplay}</div>}
</div>
);
}
return (
<Input
{...commonProps}
@ -597,31 +617,31 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
useEffect(() => {
// 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성)
const categoryFields = fields.filter(f => f.type === "category");
const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text");
const categoryFields = fields.filter((f) => f.type === "category");
const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text");
if (categoryFields.length === 0 && readonlyFields.length === 0) return;
const loadCategoryMappings = async () => {
const apiClient = (await import("@/lib/api/client")).apiClient;
// 1. 카테고리 타입 필드 매핑 로드
for (const field of categoryFields) {
const columnName = field.name;
if (categoryMappings[columnName]) continue;
try {
const tableName = config.targetTable;
if (!tableName) continue;
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color: string }> = {};
response.data.data.forEach((item: any) => {
const key = String(item.valueCode);
mapping[key] = {
@ -629,10 +649,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
color: item.color || "#64748b",
};
});
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,
}));
@ -641,29 +661,29 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
}
}
// 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드
// material, division 등 조인된 테이블의 카테고리 필드
const joinedTableFields = ['material', 'division', 'status', 'currency_code'];
const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name));
const joinedTableFields = ["material", "division", "status", "currency_code"];
const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name));
if (fieldsToLoadFromJoinedTable.length > 0) {
// item_info 테이블에서 카테고리 매핑 로드
const joinedTableName = 'item_info';
const joinedTableName = "item_info";
for (const field of fieldsToLoadFromJoinedTable) {
const columnName = field.name;
if (categoryMappings[columnName]) continue;
try {
console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`);
const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color: string }> = {};
response.data.data.forEach((item: any) => {
const key = String(item.valueCode);
mapping[key] = {
@ -671,10 +691,10 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
color: item.color || "#64748b",
};
});
console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({
setCategoryMappings((prev) => ({
...prev,
[columnName]: mapping,
}));
@ -694,9 +714,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (fields.length === 0) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-destructive/30 bg-destructive/5 p-8 text-center">
<p className="text-sm font-medium text-destructive"> </p>
<p className="mt-2 text-xs text-muted-foreground"> .</p>
<div className="border-destructive/30 bg-destructive/5 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
<p className="text-destructive text-sm font-medium"> </p>
<p className="text-muted-foreground mt-2 text-xs"> .</p>
</div>
</div>
);
@ -706,8 +726,8 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length === 0) {
return (
<div className={cn("space-y-4", className)}>
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
<p className="mb-4 text-sm text-muted-foreground">{emptyMessage}</p>
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
<p className="text-muted-foreground mb-4 text-sm">{emptyMessage}</p>
{!readonly && !disabled && items.length < maxItems && (
<Button type="button" onClick={handleAddItem} size="sm">
<Plus className="mr-2 h-4 w-4" />
@ -740,7 +760,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{fields.map((field) => (
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
</TableHead>
))}
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
@ -751,7 +771,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableRow
key={itemIndex}
className={cn(
"bg-background transition-colors hover:bg-muted/50",
"bg-background hover:bg-muted/50 transition-colors",
draggedIndex === itemIndex && "opacity-50",
)}
draggable={allowReorder && !readonly && !disabled}
@ -762,15 +782,13 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
>
{/* 인덱스 번호 */}
{showIndex && (
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
{itemIndex + 1}
</TableCell>
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">{itemIndex + 1}</TableCell>
)}
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
<GripVertical className="text-muted-foreground h-4 w-4 cursor-move" />
</TableCell>
)}
@ -789,7 +807,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
@ -829,12 +847,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className="flex items-center gap-2">
{/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && (
<GripVertical className="h-4 w-4 flex-shrink-0 cursor-move text-muted-foreground" />
<GripVertical className="text-muted-foreground h-4 w-4 flex-shrink-0 cursor-move" />
)}
{/* 인덱스 번호 */}
{showIndex && (
<CardTitle className="text-sm font-semibold text-foreground"> {itemIndex + 1}</CardTitle>
<CardTitle className="text-foreground text-sm font-semibold"> {itemIndex + 1}</CardTitle>
)}
</div>
@ -859,7 +877,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
variant="ghost"
size="icon"
onClick={() => handleRemoveItem(itemIndex)}
className="h-8 w-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-8 w-8"
title="항목 제거"
>
<X className="h-4 w-4" />
@ -873,9 +891,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<div className={getFieldsLayoutClass()}>
{fields.map((field) => (
<div key={field.name} className="space-y-1" style={{ width: field.width }}>
<label className="text-sm font-medium text-foreground">
<label className="text-foreground text-sm font-medium">
{field.label}
{field.required && <span className="ml-1 text-destructive">*</span>}
{field.required && <span className="text-destructive ml-1">*</span>}
</label>
{renderField(field, itemIndex, item[field.name])}
</div>
@ -906,7 +924,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
)}
{/* 제한 안내 */}
<div className="flex justify-between text-xs text-muted-foreground">
<div className="text-muted-foreground flex justify-between text-xs">
<span>: {items.length} </span>
<span>
(: {minItems}, : {maxItems})

View File

@ -10,7 +10,13 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
import {
RepeaterFieldGroupConfig,
RepeaterFieldDefinition,
RepeaterFieldType,
CalculationOperator,
CalculationFormula,
} from "@/types/repeater";
import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils";
@ -34,10 +40,10 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
}) => {
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
// 설정 입력 필드의 로컬 상태
const [localConfigInputs, setLocalConfigInputs] = useState({
addButtonText: config.addButtonText || "",
@ -88,13 +94,13 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
};
// 필드 수정 (입력 중 - 로컬 상태만)
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
setLocalInputs(prev => ({
const updateFieldLocal = (index: number, field: "label" | "placeholder", value: string) => {
setLocalInputs((prev) => ({
...prev,
[index]: {
...prev[index],
[field]: value
}
[field]: value,
},
}));
};
@ -106,7 +112,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
newFields[index] = {
...newFields[index],
label: localInput.label,
placeholder: localInput.placeholder
placeholder: localInput.placeholder,
};
handleFieldsChange(newFields);
}
@ -218,6 +224,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</p>
</div>
{/* 🆕 FK 컬럼 설정 (분할 패널용) */}
<div className="space-y-2">
<Label className="text-sm font-semibold">FK ( )</Label>
<Select
value={(config as any).fkColumn || "__none__"}
onValueChange={(value) => handleChange("fkColumn" as any, value === "__none__" ? undefined : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="FK 컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> ( )</SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
.
<br />
: serial_no를 serial_no에 .
</p>
</div>
{/* 필드 정의 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
@ -263,8 +295,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
onSelect={() => {
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
const col = column as any;
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
const fieldType =
col.input_type || col.inputType || col.webType || col.widgetType || "text";
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
columnName: column.columnName,
input_type: col.input_type,
@ -273,19 +306,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
widgetType: col.widgetType,
finalType: fieldType,
});
updateField(index, {
name: column.columnName,
label: column.columnLabel || column.columnName,
type: fieldType as RepeaterFieldType,
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
setLocalInputs((prev) => ({
...prev,
[index]: {
label: column.columnLabel || column.columnName,
placeholder: prev[index]?.placeholder || ""
}
placeholder: prev[index]?.placeholder || "",
},
}));
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
}}
@ -313,7 +346,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-xs"></Label>
<Input
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
onChange={(e) => updateFieldLocal(index, "label", e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨"
className="h-8 w-full text-xs"
@ -358,8 +391,12 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="space-y-1">
<Label className="text-xs">Placeholder</Label>
<Input
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
value={
localInputs[index]?.placeholder !== undefined
? localInputs[index].placeholder
: field.placeholder || ""
}
onChange={(e) => updateFieldLocal(index, "placeholder", e.target.value)}
onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내"
className="h-8 w-full text-xs"
@ -374,15 +411,17 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Calculator className="h-4 w-4 text-blue-600" />
<Label className="text-xs font-semibold text-blue-800"> </Label>
</div>
{/* 필드 1 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 1</Label>
<Select
value={field.formula?.field1 || ""}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula
})}
onValueChange={(value) =>
updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
@ -398,54 +437,75 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Select
value={field.formula?.operator || "+"}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
})}
onValueChange={(value) =>
updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula,
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="+" className="text-xs">+ </SelectItem>
<SelectItem value="-" className="text-xs">- </SelectItem>
<SelectItem value="*" className="text-xs">× </SelectItem>
<SelectItem value="/" className="text-xs">÷ </SelectItem>
<SelectItem value="%" className="text-xs">% </SelectItem>
<SelectItem value="round" className="text-xs"></SelectItem>
<SelectItem value="floor" className="text-xs"></SelectItem>
<SelectItem value="ceil" className="text-xs"></SelectItem>
<SelectItem value="+" className="text-xs">
+
</SelectItem>
<SelectItem value="-" className="text-xs">
-
</SelectItem>
<SelectItem value="*" className="text-xs">
×
</SelectItem>
<SelectItem value="/" className="text-xs">
÷
</SelectItem>
<SelectItem value="%" className="text-xs">
%
</SelectItem>
<SelectItem value="round" className="text-xs">
</SelectItem>
<SelectItem value="floor" className="text-xs">
</SelectItem>
<SelectItem value="ceil" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 두 번째 필드 또는 상수값 */}
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 2 / </Label>
<Select
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
value={
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")
}
onValueChange={(value) => {
if (value.startsWith("__const__")) {
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0
} as CalculationFormula
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0,
} as CalculationFormula,
});
} else {
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined
} as CalculationFormula
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined,
} as CalculationFormula,
});
}
}}
@ -475,14 +535,19 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
min={0}
max={10}
value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
})}
onChange={(e) =>
updateField(index, {
formula: {
...field.formula,
decimalPlaces: parseInt(e.target.value) || 0,
} as CalculationFormula,
})
}
className="h-8 text-xs"
/>
</div>
)}
{/* 상수값 입력 필드 */}
{field.formula?.constantValue !== undefined && (
<div className="space-y-1">
@ -490,15 +555,20 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Input
type="number"
value={field.formula.constantValue}
onChange={(e) => updateField(index, {
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
})}
onChange={(e) =>
updateField(index, {
formula: {
...field.formula,
constantValue: parseFloat(e.target.value) || 0,
} as CalculationFormula,
})
}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
</div>
)}
{/* 숫자 포맷 설정 */}
<div className="space-y-2 border-t border-blue-200 pt-2">
<Label className="text-[10px] text-blue-700"> </Label>
@ -507,9 +577,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox
id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
onCheckedChange={(checked) =>
updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/>
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
@ -519,9 +591,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number"
min={0}
max={10}
@ -532,31 +606,34 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
{/* 계산식 미리보기 */}
<div className="rounded bg-white p-2 text-xs">
<span className="text-gray-500">: </span>
<code className="font-mono text-blue-700">
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
}
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"}{" "}
{field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")}
</code>
</div>
</div>
@ -571,9 +648,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Checkbox
id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
onCheckedChange={(checked) =>
updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean },
})
}
/>
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
@ -583,9 +662,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 },
})
}
type="number"
min={0}
max={10}
@ -596,17 +677,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value },
})
}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
onChange={(e) =>
updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value },
})
}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
@ -624,7 +709,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
className="h-8 w-full text-xs"
/>
<p className="text-[10px] text-muted-foreground">
<p className="text-muted-foreground text-[10px]">
</p>
</div>

View File

@ -5,7 +5,7 @@
"use client";
import React, { createContext, useContext, useCallback, useRef } from "react";
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
@ -14,17 +14,21 @@ interface ScreenContextValue {
screenId?: number;
tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
formData: Record<string, any>;
updateFormData: (fieldName: string, value: any) => void;
// 컴포넌트 등록
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
unregisterDataProvider: (componentId: string) => void;
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
unregisterDataReceiver: (componentId: string) => void;
// 컴포넌트 조회
getDataProvider: (componentId: string) => DataProvidable | undefined;
getDataReceiver: (componentId: string) => DataReceivable | undefined;
// 모든 컴포넌트 조회
getAllDataProviders: () => Map<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>;
@ -42,10 +46,31 @@ interface ScreenContextProviderProps {
/**
*
*/
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
export function ScreenContextProvider({
screenId,
tableName,
splitPanelPosition,
children,
}: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
// 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장)
const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 폼 데이터 업데이트 함수
const updateFormData = useCallback((fieldName: string, value: any) => {
setFormData((prev) => {
const updated = { ...prev, [fieldName]: value };
logger.debug("ScreenContext formData 업데이트", {
fieldName,
valueType: typeof value,
isArray: Array.isArray(value),
});
return updated;
});
}, []);
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
@ -83,31 +108,38 @@ export function ScreenContextProvider({ screenId, tableName, splitPanelPosition,
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<ScreenContextValue>(() => ({
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
}), [
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
]);
const value = React.useMemo<ScreenContextValue>(
() => ({
screenId,
tableName,
splitPanelPosition,
formData,
updateFormData,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
}),
[
screenId,
tableName,
splitPanelPosition,
formData,
updateFormData,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
],
);
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
}
@ -130,4 +162,3 @@ export function useScreenContext() {
export function useScreenContextOptional() {
return useContext(ScreenContext);
}

View File

@ -192,3 +192,4 @@ export function applyAutoFillToFormData(
return result;
}

View File

@ -32,7 +32,7 @@ export const uploadFiles = async (params: {
files: FileList | File[];
tableName?: string;
fieldName?: string;
recordId?: string;
recordId?: string | number;
docType?: string;
docTypeName?: string;
targetObjid?: string;
@ -43,6 +43,7 @@ export const uploadFiles = async (params: {
columnName?: string;
isVirtualFileColumn?: boolean;
companyCode?: string; // 🔒 멀티테넌시: 회사 코드
isRecordMode?: boolean; // 🆕 레코드 모드 플래그
}): Promise<FileUploadResponse> => {
const formData = new FormData();
@ -55,7 +56,7 @@ export const uploadFiles = async (params: {
// 추가 파라미터들 추가
if (params.tableName) formData.append("tableName", params.tableName);
if (params.fieldName) formData.append("fieldName", params.fieldName);
if (params.recordId) formData.append("recordId", params.recordId);
if (params.recordId) formData.append("recordId", String(params.recordId));
if (params.docType) formData.append("docType", params.docType);
if (params.docTypeName) formData.append("docTypeName", params.docTypeName);
if (params.targetObjid) formData.append("targetObjid", params.targetObjid);
@ -66,6 +67,8 @@ export const uploadFiles = async (params: {
if (params.columnName) formData.append("columnName", params.columnName);
if (params.isVirtualFileColumn !== undefined) formData.append("isVirtualFileColumn", params.isVirtualFileColumn.toString());
if (params.companyCode) formData.append("companyCode", params.companyCode); // 🔒 멀티테넌시
// 🆕 레코드 모드 플래그 추가 (백엔드에서 attachments 컬럼 자동 업데이트용)
if (params.isRecordMode !== undefined) formData.append("isRecordMode", params.isRecordMode.toString());
const response = await apiClient.post("/files/upload", formData, {
headers: {

View File

@ -602,6 +602,9 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,

View File

@ -47,7 +47,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string;
sortOrder?: "asc" | "desc";
@ -57,10 +57,10 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 🆕 부모창에서 전달된 그룹 데이터 (모달에서 부모 데이터 접근용)
groupedData?: Record<string, any>[];
}
@ -109,11 +109,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
const effectiveTableName = tableName || screenContext?.tableName;
const effectiveScreenId = screenId || screenContext?.screenId;
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
const finalOnSave = onSave || propsOnSave;
@ -169,10 +169,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (!shouldFetchStatus) return;
let isMounted = true;
const fetchStatus = async () => {
if (!isMounted) return;
try {
const response = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
page: 1,
@ -180,12 +180,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
search: { [statusKeyField]: userId },
autoFilter: true,
});
if (!isMounted) return;
const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || [];
const firstRow = Array.isArray(rows) ? rows[0] : null;
if (response.data?.success && firstRow) {
const newStatus = firstRow[statusFieldName];
if (newStatus !== vehicleStatus) {
@ -206,10 +206,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 즉시 실행
setStatusLoading(true);
fetchStatus();
// 2초마다 갱신
const interval = setInterval(fetchStatus, 2000);
return () => {
isMounted = false;
clearInterval(interval);
@ -219,22 +219,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 버튼 비활성화 조건 계산
const isOperationButtonDisabled = useMemo(() => {
const actionConfig = component.componentConfig?.action;
if (actionConfig?.type !== "operation_control") return false;
// 1. 출발지/도착지 필수 체크
if (actionConfig?.requireLocationFields) {
const departureField = actionConfig.trackingDepartureField || "departure";
const destinationField = actionConfig.trackingArrivalField || "destination";
const departure = formData?.[departureField];
const destination = formData?.[destinationField];
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
// departureField, destinationField, departure, destination,
// buttonLabel: component.label
// console.log("🔍 [ButtonPrimary] 출발지/도착지 체크:", {
// departureField, destinationField, departure, destination,
// buttonLabel: component.label
// });
if (!departure || departure === "" || !destination || destination === "") {
// console.log("🚫 [ButtonPrimary] 출발지/도착지 미선택 → 비활성화:", component.label);
return true;
@ -246,20 +246,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const statusField = actionConfig.statusCheckField || "status";
// API 조회 결과를 우선 사용 (실시간 DB 상태 반영)
const currentStatus = vehicleStatus || formData?.[statusField];
const conditionType = actionConfig.statusConditionType || "enableOn";
const conditionValues = (actionConfig.statusConditionValues || "")
.split(",")
.map((v: string) => v.trim())
.filter((v: string) => v);
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
// console.log("🔍 [ButtonPrimary] 상태 조건 체크:", {
// statusField,
// formDataStatus: formData?.[statusField],
// apiStatus: vehicleStatus,
// currentStatus,
// conditionType,
// conditionValues,
// currentStatus,
// conditionType,
// conditionValues,
// buttonLabel: component.label,
// });
@ -274,7 +274,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// console.log("🚫 [ButtonPrimary] 상태값 없음 → 비활성화:", component.label);
return true;
}
if (conditionValues.length > 0) {
if (conditionType === "enableOn") {
// 이 상태일 때만 활성화
@ -551,7 +551,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
*/
const handleTransferDataAction = async (actionConfig: any) => {
const dataTransferConfig = actionConfig.dataTransfer;
if (!dataTransferConfig) {
toast.error("데이터 전달 설정이 없습니다.");
return;
@ -565,15 +565,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
try {
// 1. 소스 컴포넌트에서 데이터 가져오기
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
if (!sourceProvider) {
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색
for (const [id, provider] of allProviders) {
if (provider.componentType === "table-list") {
@ -582,16 +582,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
break;
}
}
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
if (!sourceProvider && allProviders.size > 0) {
const firstEntry = allProviders.entries().next().value;
if (firstEntry) {
sourceProvider = firstEntry[1];
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
console.log(
`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`,
);
}
}
if (!sourceProvider) {
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
return;
@ -599,12 +601,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
const rawSourceData = sourceProvider.getSelectedData();
// 🆕 배열이 아닌 경우 배열로 변환
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : rawSourceData ? [rawSourceData] : [];
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
if (!sourceData || sourceData.length === 0) {
toast.warning("선택된 데이터가 없습니다.");
return;
@ -612,31 +614,32 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
let additionalData: Record<string, any> = {};
// 방법 1: additionalSources 설정에서 가져오기
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
for (const additionalSource of dataTransferConfig.additionalSources) {
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
if (additionalProvider) {
const additionalValues = additionalProvider.getSelectedData();
if (additionalValues && additionalValues.length > 0) {
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
const firstValue = additionalValues[0];
// fieldName이 지정되어 있으면 그 필드만 추출
if (additionalSource.fieldName) {
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
additionalData[additionalSource.fieldName] =
firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
} else {
// fieldName이 없으면 전체 객체 병합
additionalData = { ...additionalData, ...firstValue };
}
console.log("📦 추가 데이터 수집 (additionalSources):", {
sourceId: additionalSource.componentId,
fieldName: additionalSource.fieldName,
value: additionalData[additionalSource.fieldName || 'all'],
value: additionalData[additionalSource.fieldName || "all"],
});
}
}
@ -651,7 +654,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const conditionalValue = formData.__conditionalContainerValue;
const conditionalLabel = formData.__conditionalContainerLabel;
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
if (controlField) {
additionalData[controlField] = conditionalValue;
@ -663,7 +666,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} else {
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
for (const [key, value] of Object.entries(formData)) {
if (value === conditionalValue && !key.startsWith('__')) {
if (value === conditionalValue && !key.startsWith("__")) {
additionalData[key] = conditionalValue;
console.log("📦 조건부 컨테이너 값 자동 포함:", {
fieldName: key,
@ -673,12 +676,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
break;
}
}
// 못 찾았으면 기본 필드명 사용
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
additionalData['condition_type'] = conditionalValue;
if (!Object.keys(additionalData).some((k) => !k.startsWith("__"))) {
additionalData["condition_type"] = conditionalValue;
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
fieldName: 'condition_type',
fieldName: "condition_type",
value: conditionalValue,
});
}
@ -710,7 +713,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 4. 매핑 규칙 적용 + 추가 데이터 병합
const mappedData = sourceData.map((row) => {
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
// 추가 데이터를 모든 행에 포함
return {
...mappedRow,
@ -730,7 +733,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (dataTransferConfig.targetType === "component") {
// 같은 화면의 컴포넌트로 전달
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
if (!targetReceiver) {
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
return;
@ -742,7 +745,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
mode: dataTransferConfig.mode || "append",
mappingRules: dataTransferConfig.mappingRules || [],
});
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
} else if (dataTransferConfig.targetType === "splitPanel") {
// 🆕 분할 패널의 반대편 화면으로 전달
@ -750,17 +753,18 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
return;
}
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
const currentPosition =
splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
if (!currentPosition) {
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
return;
}
console.log("📦 분할 패널 데이터 전달:", {
currentPosition,
splitPanelPositionFromHook: splitPanelPosition,
@ -768,14 +772,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
leftScreenId: splitPanelContext.leftScreenId,
rightScreenId: splitPanelContext.rightScreenId,
});
const result = await splitPanelContext.transferToOtherSide(
currentPosition,
mappedData,
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
dataTransferConfig.mode || "append"
dataTransferConfig.mode || "append",
);
if (result.success) {
toast.success(result.message);
} else {
@ -794,7 +798,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
if (dataTransferConfig.clearAfterTransfer) {
sourceProvider.clearSelection();
}
} catch (error: any) {
console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
@ -828,16 +831,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 2. groupedData (부모창에서 모달로 전달된 데이터)
// 3. modalDataStore (분할 패널 등에서 선택한 데이터)
let effectiveSelectedRowsData = selectedRowsData;
// groupedData가 있으면 우선 사용 (모달에서 부모 데이터 접근)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && groupedData && groupedData.length > 0) {
if (
(!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) &&
groupedData &&
groupedData.length > 0
) {
effectiveSelectedRowsData = groupedData;
console.log("🔗 [ButtonPrimaryComponent] groupedData에서 부모창 데이터 가져옴:", {
count: groupedData.length,
data: groupedData,
});
}
// modalDataStore에서 선택된 데이터 가져오기 (분할 패널 등에서 선택한 데이터)
if ((!effectiveSelectedRowsData || effectiveSelectedRowsData.length === 0) && effectiveTableName) {
try {
@ -845,11 +852,17 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const dataRegistry = useModalDataStore.getState().dataRegistry;
const modalData = dataRegistry[effectiveTableName];
if (modalData && modalData.length > 0) {
effectiveSelectedRowsData = modalData;
// modalDataStore는 {id, originalData, additionalData} 형태로 저장됨
// originalData를 추출하여 실제 행 데이터를 가져옴
effectiveSelectedRowsData = modalData.map((item: any) => {
// originalData가 있으면 그것을 사용, 없으면 item 자체 사용 (하위 호환성)
return item.originalData || item;
});
console.log("🔗 [ButtonPrimaryComponent] modalDataStore에서 선택된 데이터 가져옴:", {
tableName: effectiveTableName,
count: modalData.length,
data: modalData,
rawData: modalData,
extractedData: effectiveSelectedRowsData,
});
}
} catch (error) {
@ -859,7 +872,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete =
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
(effectiveSelectedRowsData && effectiveSelectedRowsData.length > 0) ||
(flowSelectedData && flowSelectedData.length > 0);
if (processedConfig.action.type === "delete" && !hasDataToDelete) {
toast.warning("삭제할 항목을 먼저 선택해주세요.");
@ -905,8 +919,27 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
}
// 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합
// screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터
// props.formData: 부모에서 전달된 폼 데이터
const screenContextFormData = screenContext?.formData || {};
const propsFormData = formData || {};
// 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드
// (RepeaterFieldGroup 데이터는 screenContext에만 있음)
const effectiveFormData = { ...propsFormData, ...screenContextFormData };
console.log("🔍 [ButtonPrimary] formData 선택:", {
hasScreenContextFormData: Object.keys(screenContextFormData).length > 0,
screenContextKeys: Object.keys(screenContextFormData),
hasPropsFormData: Object.keys(propsFormData).length > 0,
propsFormDataKeys: Object.keys(propsFormData),
splitPanelPosition,
effectiveFormDataKeys: Object.keys(effectiveFormData),
});
const context: ButtonActionContext = {
formData: formData || {},
formData: effectiveFormData,
originalData: originalData, // 🔧 빈 객체 대신 undefined 유지 (UPDATE 판단에 사용)
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
@ -996,6 +1029,9 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
flowSelectedStepId: _flowSelectedStepId, // 플로우 선택 스텝 ID 필터링
onFlowRefresh: _onFlowRefresh, // 플로우 새로고침 콜백 필터링
originalData: _originalData, // 부분 업데이트용 원본 데이터 필터링
_originalData: __originalData, // DOM 필터링
_initialData: __initialData, // DOM 필터링
_groupedData: __groupedData, // DOM 필터링
refreshKey: _refreshKey, // 필터링 추가
isInModal: _isInModal, // 필터링 추가
mode: _mode, // 필터링 추가
@ -1073,15 +1109,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
alignItems: "center",
justifyContent: "center",
// 🔧 크기에 따른 패딩 조정
padding:
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
margin: "0",
lineHeight: "1.25",
boxShadow: finalDisabled ? "none" : "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
// 디자인 모드와 인터랙티브 모드 모두에서 사용자 스타일 적용 (width/height 제외)
...(component.style ? Object.fromEntries(
Object.entries(component.style).filter(([key]) => key !== 'width' && key !== 'height')
) : {}),
...(component.style
? Object.fromEntries(Object.entries(component.style).filter(([key]) => key !== "width" && key !== "height"))
: {}),
};
const buttonContent = processedConfig.text !== undefined ? processedConfig.text : component.label || "버튼";
@ -1103,7 +1138,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
<button
type={componentConfig.actionType || "button"}
disabled={finalDisabled}
className="transition-colors duration-150 hover:opacity-90 active:scale-95 transition-transform"
className="transition-colors transition-transform duration-150 hover:opacity-90 active:scale-95"
style={buttonElementStyle}
onClick={handleClick}
onDragStart={onDragStart}

View File

@ -86,6 +86,9 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
isInModal: _isInModal,
readonly: _readonly,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
onUpdateLayout: _onUpdateLayout,
selectedRows: _selectedRows,

View File

@ -10,6 +10,7 @@ import { apiClient } from "@/lib/api/client";
import { FileViewerModal } from "./FileViewerModal";
import { FileManagerModal } from "./FileManagerModal";
import { FileUploadConfig, FileInfo, FileUploadStatus, FileUploadResponse } from "./types";
import { useAuth } from "@/hooks/useAuth";
import {
Upload,
File,
@ -92,6 +93,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
onDragEnd,
onUpdate,
}) => {
// 🔑 인증 정보 가져오기
const { user } = useAuth();
const [uploadedFiles, setUploadedFiles] = useState<FileInfo[]>([]);
const [uploadStatus, setUploadStatus] = useState<FileUploadStatus>("idle");
const [dragOver, setDragOver] = useState(false);
@ -102,28 +106,94 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const [representativeImageUrl, setRepresentativeImageUrl] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 🔑 레코드 모드 판단: formData에 id가 있으면 특정 행에 연결된 파일 관리
const isRecordMode = !!(formData?.id && !String(formData.id).startsWith('temp_'));
const recordTableName = formData?.tableName || component.tableName;
const recordId = formData?.id;
// 🔑 컬럼명 결정: 레코드 모드에서는 무조건 'attachments' 사용
// component.columnName이나 component.id는 '파일_업로드' 같은 한글 라벨일 수 있어서 DB 컬럼명으로 부적합
// 레코드 모드가 아닐 때만 component.columnName 또는 component.id 사용
const columnName = isRecordMode ? 'attachments' : (component.columnName || component.id || 'attachments');
// 🔑 레코드 모드용 targetObjid 생성
const getRecordTargetObjid = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
return `${recordTableName}:${recordId}:${columnName}`;
}
return null;
}, [isRecordMode, recordTableName, recordId, columnName]);
// 🔑 레코드별 고유 키 생성 (localStorage, 전역 상태용)
const getUniqueKey = useCallback(() => {
if (isRecordMode && recordTableName && recordId) {
// 레코드 모드: 테이블명:레코드ID:컴포넌트ID 형태로 고유 키 생성
return `fileUpload_${recordTableName}_${recordId}_${component.id}`;
}
// 기본 모드: 컴포넌트 ID만 사용
return `fileUpload_${component.id}`;
}, [isRecordMode, recordTableName, recordId, component.id]);
// 🔍 디버깅: 레코드 모드 상태 로깅
useEffect(() => {
console.log("📎 [FileUploadComponent] 모드 확인:", {
isRecordMode,
recordTableName,
recordId,
columnName,
targetObjid: getRecordTargetObjid(),
uniqueKey: getUniqueKey(),
formDataKeys: formData ? Object.keys(formData) : [],
// 🔍 추가 디버깅: 어디서 tableName이 오는지 확인
"formData.tableName": formData?.tableName,
"component.tableName": component.tableName,
"component.columnName": component.columnName,
"component.id": component.id,
});
}, [isRecordMode, recordTableName, recordId, columnName, getRecordTargetObjid, getUniqueKey, formData, component.tableName, component.columnName, component.id]);
// 🆕 레코드 ID 변경 시 파일 목록 초기화 및 새로 로드
const prevRecordIdRef = useRef<any>(null);
useEffect(() => {
if (prevRecordIdRef.current !== recordId) {
console.log("📎 [FileUploadComponent] 레코드 ID 변경 감지:", {
prev: prevRecordIdRef.current,
current: recordId,
isRecordMode,
});
prevRecordIdRef.current = recordId;
// 레코드 모드에서 레코드 ID가 변경되면 파일 목록 초기화
if (isRecordMode) {
setUploadedFiles([]);
}
}
}, [recordId, isRecordMode]);
// 컴포넌트 마운트 시 즉시 localStorage에서 파일 복원
useEffect(() => {
if (!component?.id) return;
try {
const backupKey = `fileUpload_${component.id}`;
// 🔑 레코드별 고유 키 사용
const backupKey = getUniqueKey();
const backupFiles = localStorage.getItem(backupKey);
if (backupFiles) {
const parsedFiles = JSON.parse(backupFiles);
if (parsedFiles.length > 0) {
console.log("🚀 컴포넌트 마운트 시 파일 즉시 복원:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
restoredFiles: parsedFiles.length,
files: parsedFiles.map((f: any) => ({ objid: f.objid, name: f.realFileName })),
});
setUploadedFiles(parsedFiles);
// 전역 상태에도 복원
// 전역 상태에도 복원 (레코드별 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: parsedFiles,
[backupKey]: parsedFiles,
};
}
}
@ -131,7 +201,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
} catch (e) {
console.warn("컴포넌트 마운트 시 파일 복원 실패:", e);
}
}, [component.id]); // component.id가 변경될 때만 실행
}, [component.id, getUniqueKey, recordId]); // 레코드별 고유 키 변경 시 재실행
// 🎯 화면설계 모드에서 실제 화면으로의 실시간 동기화 이벤트 리스너
useEffect(() => {
@ -152,12 +222,14 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const newFiles = event.detail.files || [];
setUploadedFiles(newFiles);
// localStorage 백업 업데이트
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(newFiles));
console.log("💾 화면설계 모드 동기화 후 localStorage 백업 업데이트:", {
uniqueKey: backupKey,
componentId: component.id,
recordId: recordId,
fileCount: newFiles.length,
});
} catch (e) {
@ -201,6 +273,16 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
if (!component?.id) return false;
try {
// 🔑 레코드 모드: 해당 행의 파일만 조회
if (isRecordMode && recordTableName && recordId) {
console.log("📂 [FileUploadComponent] 레코드 모드 파일 조회:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
targetObjid: getRecordTargetObjid(),
});
}
// 1. formData에서 screenId 가져오기
let screenId = formData?.screenId;
@ -232,11 +314,13 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const params = {
screenId,
componentId: component.id,
tableName: formData?.tableName || component.tableName,
recordId: formData?.id,
columnName: component.columnName || component.id, // 🔑 columnName이 없으면 component.id 사용
tableName: recordTableName || formData?.tableName || component.tableName,
recordId: recordId || formData?.id,
columnName: columnName, // 🔑 레코드 모드에서 사용하는 columnName
};
console.log("📂 [FileUploadComponent] 파일 조회 파라미터:", params);
const response = await getComponentFiles(params);
if (response.success) {
@ -255,11 +339,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}));
// 🔄 localStorage의 기존 파일과 서버 파일 병합
// 🔄 localStorage의 기존 파일과 서버 파일 병합 (레코드별 고유 키 사용)
let finalFiles = formattedFiles;
const uniqueKey = getUniqueKey();
try {
const backupKey = `fileUpload_${component.id}`;
const backupFiles = localStorage.getItem(backupKey);
const backupFiles = localStorage.getItem(uniqueKey);
if (backupFiles) {
const parsedBackupFiles = JSON.parse(backupFiles);
@ -268,7 +352,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const additionalFiles = parsedBackupFiles.filter((f: any) => !serverObjIds.has(f.objid));
finalFiles = [...formattedFiles, ...additionalFiles];
console.log("📂 [FileUploadComponent] 파일 병합 완료:", {
uniqueKey,
serverFiles: formattedFiles.length,
localFiles: parsedBackupFiles.length,
finalFiles: finalFiles.length,
});
}
} catch (e) {
console.warn("파일 병합 중 오류:", e);
@ -276,11 +365,11 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(finalFiles);
// 전역 상태에도 저장
// 전역 상태에도 저장 (레코드별 고유 키 사용)
if (typeof window !== "undefined") {
(window as any).globalFileState = {
...(window as any).globalFileState,
[component.id]: finalFiles,
[uniqueKey]: finalFiles,
};
// 🌐 전역 파일 저장소에 등록 (페이지 간 공유용)
@ -288,12 +377,12 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId,
});
// localStorage 백업도 병합된 파일로 업데이트
// localStorage 백업도 병합된 파일로 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
localStorage.setItem(backupKey, JSON.stringify(finalFiles));
localStorage.setItem(uniqueKey, JSON.stringify(finalFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
}
@ -304,7 +393,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.error("파일 조회 오류:", error);
}
return false; // 기존 로직 사용
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id]);
}, [component.id, component.tableName, component.columnName, formData?.screenId, formData?.tableName, formData?.id, getUniqueKey, recordId, isRecordMode, recordTableName, columnName]);
// 컴포넌트 파일 동기화 (DB 우선, localStorage는 보조)
useEffect(() => {
@ -316,6 +405,8 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
componentFiles: componentFiles.length,
formData: formData,
screenId: formData?.screenId,
tableName: formData?.tableName, // 🔍 테이블명 확인
recordId: formData?.id, // 🔍 레코드 ID 확인
currentUploadedFiles: uploadedFiles.length,
});
@ -371,9 +462,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(files);
setForceUpdate((prev) => prev + 1);
// localStorage 백업도 업데이트
// localStorage 백업도 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(files));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@ -462,10 +553,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
toast.loading("파일을 업로드하는 중...", { id: "file-upload" });
try {
// targetObjid 생성 - 템플릿 vs 데이터 파일 구분
const tableName = formData?.tableName || component.tableName || "default_table";
const recordId = formData?.id;
const columnName = component.columnName || component.id;
// 🔑 레코드 모드 우선 사용
const effectiveTableName = recordTableName || formData?.tableName || component.tableName || "default_table";
const effectiveRecordId = recordId || formData?.id;
const effectiveColumnName = columnName;
// screenId 추출 (우선순위: formData > URL)
let screenId = formData?.screenId;
@ -478,47 +569,84 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
let targetObjid;
// 우선순위: 1) 실제 데이터 (recordId가 숫자/문자열이고 temp_가 아님) > 2) 템플릿 (screenId) > 3) 기본값
const isRealRecord = recordId && typeof recordId !== 'undefined' && !String(recordId).startsWith('temp_');
// 🔑 레코드 모드 판단 개선
const effectiveIsRecordMode = isRecordMode || (effectiveRecordId && !String(effectiveRecordId).startsWith('temp_'));
if (isRealRecord && tableName) {
// 실제 데이터 파일 (진짜 레코드 ID가 있을 때만)
targetObjid = `${tableName}:${recordId}:${columnName}`;
console.log("📁 실제 데이터 파일 업로드:", targetObjid);
if (effectiveIsRecordMode && effectiveTableName && effectiveRecordId) {
// 🎯 레코드 모드: 특정 행에 파일 연결
targetObjid = `${effectiveTableName}:${effectiveRecordId}:${effectiveColumnName}`;
console.log("📁 [레코드 모드] 파일 업로드:", {
targetObjid,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
});
} else if (screenId) {
// 🔑 템플릿 파일 (백엔드 조회 형식과 동일하게)
targetObjid = `screen_files:${screenId}:${component.id}:${columnName}`;
targetObjid = `screen_files:${screenId}:${component.id}:${effectiveColumnName}`;
console.log("📝 [템플릿 모드] 파일 업로드:", targetObjid);
} else {
// 기본값 (화면관리에서 사용)
targetObjid = `temp_${component.id}`;
console.log("📝 기본 파일 업로드:", targetObjid);
console.log("📝 [기본 모드] 파일 업로드:", targetObjid);
}
// 🔒 현재 사용자의 회사 코드 가져오기 (멀티테넌시 격리)
const userCompanyCode = (window as any).__user__?.companyCode;
const userCompanyCode = user?.companyCode || (window as any).__user__?.companyCode;
console.log("📤 [FileUploadComponent] 파일 업로드 준비:", {
userCompanyCode,
isRecordMode: effectiveIsRecordMode,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
// 🔑 레코드 모드일 때는 effectiveTableName을 우선 사용
// formData.linkedTable이 'screen_files' 같은 기본값일 수 있으므로 레코드 모드에서는 무시
const finalLinkedTable = effectiveIsRecordMode
? effectiveTableName
: (formData?.linkedTable || effectiveTableName);
const uploadData = {
// 🎯 formData에서 백엔드 API 설정 가져오기
autoLink: formData?.autoLink || true,
linkedTable: formData?.linkedTable || tableName,
recordId: formData?.recordId || recordId || `temp_${component.id}`,
columnName: formData?.columnName || columnName,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId || `temp_${component.id}`,
columnName: effectiveColumnName,
isVirtualFileColumn: formData?.isVirtualFileColumn || true,
docType: component.fileConfig?.docType || "DOCUMENT",
docTypeName: component.fileConfig?.docTypeName || "일반 문서",
companyCode: userCompanyCode, // 🔒 멀티테넌시: 회사 코드 명시적 전달
// 호환성을 위한 기존 필드들
tableName: tableName,
fieldName: columnName,
tableName: effectiveTableName,
fieldName: effectiveColumnName,
targetObjid: targetObjid, // InteractiveDataTable 호환을 위한 targetObjid 추가
// 🆕 레코드 모드 플래그
isRecordMode: effectiveIsRecordMode,
};
console.log("📤 [FileUploadComponent] uploadData 최종:", {
isRecordMode: effectiveIsRecordMode,
linkedTable: finalLinkedTable,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
});
console.log("🚀 [FileUploadComponent] uploadFiles API 호출 직전:", {
filesCount: filesToUpload.length,
uploadData,
});
const response = await uploadFiles({
files: filesToUpload,
...uploadData,
});
console.log("📥 [FileUploadComponent] uploadFiles API 응답:", response);
if (response.success) {
// FileUploadResponse 타입에 맞게 files 배열 사용
@ -553,9 +681,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
setUploadedFiles(updatedFiles);
setUploadStatus("success");
// localStorage 백업
// localStorage 백업 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 실패:", e);
@ -563,9 +691,10 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
// 전역 파일 상태 업데이트
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 🌐 전역 파일 저장소에 새 파일 등록 (페이지 간 공유용)
@ -573,12 +702,15 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
uploadPage: window.location.pathname,
componentId: component.id,
screenId: formData?.screenId,
recordId: recordId, // 🆕 레코드 ID 추가
});
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
@ -612,22 +744,54 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
console.warn("⚠️ onUpdate 콜백이 없습니다!");
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (formData 업데이트)
if (effectiveIsRecordMode && onFormDataChange) {
// 파일 정보를 간소화하여 attachments 컬럼에 저장할 형태로 변환
const attachmentsData = updatedFiles.map(file => ({
objid: file.objid,
realFileName: file.realFileName,
fileSize: file.fileSize,
fileExt: file.fileExt,
filePath: file.filePath,
regdate: file.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] attachments 컬럼 동기화:", {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
fileCount: attachmentsData.length,
});
// onFormDataChange를 통해 부모 컴포넌트에 attachments 업데이트 알림
onFormDataChange({
[effectiveColumnName]: attachmentsData,
// 🆕 백엔드에서 attachments 컬럼 업데이트를 위한 메타 정보
__attachmentsUpdate: {
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
files: attachmentsData,
}
});
}
// 그리드 파일 상태 새로고침 이벤트 발생
if (typeof window !== "undefined") {
const refreshEvent = new CustomEvent("refreshFileStatus", {
detail: {
tableName: tableName,
recordId: recordId,
columnName: columnName,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid: targetObjid,
fileCount: updatedFiles.length,
},
});
window.dispatchEvent(refreshEvent);
console.log("🔄 그리드 파일 상태 새로고침 이벤트 발생:", {
tableName,
recordId,
columnName,
tableName: effectiveTableName,
recordId: effectiveRecordId,
columnName: effectiveColumnName,
targetObjid,
fileCount: updatedFiles.length,
});
@ -705,9 +869,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
const updatedFiles = uploadedFiles.filter((f) => f.objid !== fileId);
setUploadedFiles(updatedFiles);
// localStorage 백업 업데이트
// localStorage 백업 업데이트 (레코드별 고유 키 사용)
try {
const backupKey = `fileUpload_${component.id}`;
const backupKey = getUniqueKey();
localStorage.setItem(backupKey, JSON.stringify(updatedFiles));
} catch (e) {
console.warn("localStorage 백업 업데이트 실패:", e);
@ -715,15 +879,18 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
// 전역 상태 업데이트 (모든 파일 컴포넌트 동기화)
if (typeof window !== "undefined") {
// 전역 파일 상태 업데이트
// 전역 파일 상태 업데이트 (레코드별 고유 키 사용)
const globalFileState = (window as any).globalFileState || {};
globalFileState[component.id] = updatedFiles;
const uniqueKey = getUniqueKey();
globalFileState[uniqueKey] = updatedFiles;
(window as any).globalFileState = globalFileState;
// 모든 파일 컴포넌트에 동기화 이벤트 발생
const syncEvent = new CustomEvent("globalFileStateChanged", {
detail: {
componentId: component.id,
uniqueKey: uniqueKey, // 🆕 고유 키 추가
recordId: recordId, // 🆕 레코드 ID 추가
files: updatedFiles,
fileCount: updatedFiles.length,
timestamp: Date.now(),
@ -749,13 +916,42 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
});
}
// 🆕 레코드 모드: attachments 컬럼 동기화 (파일 삭제 후)
if (isRecordMode && onFormDataChange && recordTableName && recordId) {
const attachmentsData = updatedFiles.map(f => ({
objid: f.objid,
realFileName: f.realFileName,
fileSize: f.fileSize,
fileExt: f.fileExt,
filePath: f.filePath,
regdate: f.regdate || new Date().toISOString(),
}));
console.log("📎 [레코드 모드] 파일 삭제 후 attachments 동기화:", {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
remainingFiles: attachmentsData.length,
});
onFormDataChange({
[columnName]: attachmentsData,
__attachmentsUpdate: {
tableName: recordTableName,
recordId: recordId,
columnName: columnName,
files: attachmentsData,
}
});
}
toast.success(`${fileName} 삭제 완료`);
} catch (error) {
console.error("파일 삭제 오류:", error);
toast.error("파일 삭제에 실패했습니다.");
}
},
[uploadedFiles, onUpdate, component.id],
[uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey],
);
// 대표 이미지 Blob URL 로드

View File

@ -53,6 +53,9 @@ export interface LocationSwapSelectorProps {
formData?: Record<string, any>;
onFormDataChange?: (field: string, value: any) => void;
// 🆕 사용자 정보 (DB에서 초기값 로드용)
userId?: string;
// componentConfig (화면 디자이너에서 전달)
componentConfig?: {
dataSource?: DataSourceConfig;
@ -65,6 +68,10 @@ export interface LocationSwapSelectorProps {
showSwapButton?: boolean;
swapButtonPosition?: "center" | "right";
variant?: "card" | "inline" | "minimal";
// 🆕 DB 초기값 로드 설정
loadFromDb?: boolean; // DB에서 초기값 로드 여부
dbTableName?: string; // 조회할 테이블명 (기본: vehicles)
dbKeyField?: string; // 키 필드 (기본: user_id)
};
}
@ -80,6 +87,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
formData = {},
onFormDataChange,
componentConfig,
userId,
} = props;
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
@ -93,6 +101,11 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
const variant = config.variant || props.variant || "card";
// 🆕 DB 초기값 로드 설정
const loadFromDb = config.loadFromDb !== false; // 기본값 true
const dbTableName = config.dbTableName || "vehicles";
const dbKeyField = config.dbKeyField || "user_id";
// 기본 옵션 (포항/광양)
const DEFAULT_OPTIONS: LocationOption[] = [
@ -104,6 +117,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
const [options, setOptions] = useState<LocationOption[]>(DEFAULT_OPTIONS);
const [loading, setLoading] = useState(false);
const [isSwapping, setIsSwapping] = useState(false);
const [dbLoaded, setDbLoaded] = useState(false); // DB 로드 완료 여부
// 로컬 선택 상태 (Select 컴포넌트용)
const [localDeparture, setLocalDeparture] = useState<string>("");
@ -193,8 +207,89 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
loadOptions();
}, [dataSource, isDesignMode]);
// formData에서 초기값 동기화
// 🆕 DB에서 초기값 로드 (새로고침 시에도 출발지/목적지 유지)
useEffect(() => {
const loadFromDatabase = async () => {
// 디자인 모드이거나, DB 로드 비활성화이거나, userId가 없으면 스킵
if (isDesignMode || !loadFromDb || !userId) {
console.log("[LocationSwapSelector] DB 로드 스킵:", { isDesignMode, loadFromDb, userId });
return;
}
// 이미 로드했으면 스킵
if (dbLoaded) {
return;
}
try {
console.log("[LocationSwapSelector] DB에서 출발지/목적지 로드 시작:", { dbTableName, dbKeyField, userId });
const response = await apiClient.post(
`/table-management/tables/${dbTableName}/data`,
{
page: 1,
size: 1,
search: { [dbKeyField]: userId },
autoFilter: true,
}
);
const vehicleData = response.data?.data?.data?.[0] || response.data?.data?.rows?.[0];
if (vehicleData) {
const dbDeparture = vehicleData[departureField] || vehicleData.departure;
const dbDestination = vehicleData[destinationField] || vehicleData.arrival || vehicleData.destination;
console.log("[LocationSwapSelector] DB에서 로드된 값:", { dbDeparture, dbDestination });
// DB에 값이 있으면 로컬 상태 및 formData 업데이트
if (dbDeparture && options.some(o => o.value === dbDeparture)) {
setLocalDeparture(dbDeparture);
onFormDataChange?.(departureField, dbDeparture);
// 라벨도 업데이트
if (departureLabelField) {
const opt = options.find(o => o.value === dbDeparture);
if (opt) {
onFormDataChange?.(departureLabelField, opt.label);
}
}
}
if (dbDestination && options.some(o => o.value === dbDestination)) {
setLocalDestination(dbDestination);
onFormDataChange?.(destinationField, dbDestination);
// 라벨도 업데이트
if (destinationLabelField) {
const opt = options.find(o => o.value === dbDestination);
if (opt) {
onFormDataChange?.(destinationLabelField, opt.label);
}
}
}
}
setDbLoaded(true);
} catch (error) {
console.error("[LocationSwapSelector] DB 로드 실패:", error);
setDbLoaded(true); // 실패해도 다시 시도하지 않음
}
};
// 옵션이 로드된 후에 DB 로드 실행
if (options.length > 0) {
loadFromDatabase();
}
}, [userId, loadFromDb, dbTableName, dbKeyField, departureField, destinationField, options, isDesignMode, dbLoaded, onFormDataChange, departureLabelField, destinationLabelField]);
// formData에서 초기값 동기화 (DB 로드 후에도 formData 변경 시 반영)
useEffect(() => {
// DB 로드가 완료되지 않았으면 스킵 (DB 값 우선)
if (loadFromDb && userId && !dbLoaded) {
return;
}
const depVal = formData[departureField];
const destVal = formData[destinationField];
@ -204,7 +299,7 @@ export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps)
if (destVal && options.some(o => o.value === destVal)) {
setLocalDestination(destVal);
}
}, [formData, departureField, destinationField, options]);
}, [formData, departureField, destinationField, options, loadFromDb, userId, dbLoaded]);
// 출발지 변경
const handleDepartureChange = (selectedValue: string) => {

View File

@ -470,6 +470,58 @@ export function LocationSwapSelectorConfigPanel({
</div>
</div>
{/* DB 초기값 로드 설정 */}
<div className="space-y-2 border-t pt-4">
<h4 className="text-sm font-medium">DB </h4>
<p className="text-xs text-muted-foreground">
DB에 /
</p>
<div className="flex items-center justify-between">
<Label>DB에서 </Label>
<Switch
checked={config?.loadFromDb !== false}
onCheckedChange={(checked) => handleChange("loadFromDb", checked)}
/>
</div>
{config?.loadFromDb !== false && (
<>
<div className="space-y-2">
<Label> </Label>
<Select
value={config?.dbTableName || "vehicles"}
onValueChange={(value) => handleChange("dbTableName", value)}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="vehicles">vehicles ()</SelectItem>
{tables.map((table) => (
<SelectItem key={table.name} value={table.name}>
{table.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
value={config?.dbKeyField || "user_id"}
onChange={(e) => handleChange("dbKeyField", e.target.value)}
placeholder="user_id"
/>
<p className="text-xs text-muted-foreground">
ID로 (기본: user_id)
</p>
</div>
</>
)}
</div>
{/* 안내 */}
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
@ -480,6 +532,8 @@ export function LocationSwapSelectorConfigPanel({
2. /
<br />
3.
<br />
4. DB
</p>
</div>
</div>

View File

@ -52,11 +52,15 @@ export function RepeatScreenModalComponent({
config,
className,
groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지)
_initialData,
_originalData: _propsOriginalData,
_groupedData,
...props
}: RepeatScreenModalComponentProps) {
}: RepeatScreenModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
// props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음)
// DynamicComponentRenderer에서는 _groupedData로 전달됨
const groupedData = propsGroupedData || (props as any).groupedData || (props as any)._groupedData;
const groupedData = propsGroupedData || (props as any).groupedData || _groupedData;
const componentConfig = {
...config,
...component?.config,

View File

@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const receiverRef = useRef<DataReceivable | null>(null);
// 🆕 그룹화된 데이터를 저장하는 상태
const [groupedData, setGroupedData] = useState<any[] | null>(null);
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
const groupDataLoadedRef = useRef(false);
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
// 🆕 DB에서 로드한 컬럼 정보 (webType 등)
const [columnInfo, setColumnInfo] = useState<Record<string, any>>({});
// 컴포넌트의 필드명 (formData 키)
const fieldName = (component as any).columnName || component.id;
// repeaterConfig 또는 componentConfig에서 설정 가져오기
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = config.groupByColumn;
const targetTable = config.targetTable;
const groupByColumn = rawConfig.groupByColumn;
const targetTable = rawConfig.targetTable;
// 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑)
const config = useMemo(() => {
const rawFields = rawConfig.fields || [];
console.log("📋 [RepeaterFieldGroup] config 생성:", {
rawFieldsCount: rawFields.length,
rawFieldNames: rawFields.map((f: any) => f.name),
columnInfoKeys: Object.keys(columnInfo),
hasColumnInfo: Object.keys(columnInfo).length > 0,
});
const fields = rawFields.map((field: any) => {
const colInfo = columnInfo[field.name];
// DB의 webType 또는 web_type을 field.type으로 적용
const dbWebType = colInfo?.webType || colInfo?.web_type;
// 타입 오버라이드 조건:
// 1. field.type이 없거나
// 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우
const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text");
if (colInfo && dbWebType && shouldOverride) {
console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name}${dbWebType}`);
return { ...field, type: dbWebType };
}
return field;
});
return { ...rawConfig, fields };
}, [rawConfig, columnInfo]);
// formData에서 값 가져오기 (value prop보다 우선)
const rawValue = formData?.[fieldName] ?? value;
@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
const isEditMode = formData?.id && !rawValue && !value;
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
const configFields = config.fields || [];
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
configFields.some((field: any) => formData?.[field.name] !== undefined);
const hasRepeaterFieldsInFormData =
configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined);
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
fieldName,
hasFormData: !!formData,
// 🆕 분할 패널 위치 및 좌측 선택 데이터 확인
const splitPanelPosition = screenContext?.splitPanelPosition;
const isRightPanel = splitPanelPosition === "right";
const selectedLeftData = splitPanelContext?.selectedLeftData;
// 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기
// screen-split-panel에서 설정한 linkedFilters 사용
const linkedFilters = splitPanelContext?.linkedFilters || [];
const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues;
// 🆕 FK 컬럼 설정 우선순위:
// 1. linkedFilters에서 targetTable에 해당하는 설정 찾기
// 2. config.fkColumn (컴포넌트 설정)
// 3. config.groupByColumn (그룹화 컬럼)
let fkSourceColumn: string | null = null;
let fkTargetColumn: string | null = null;
let linkedFilterTargetTable: string | null = null;
// linkedFilters에서 FK 컬럼 찾기
if (linkedFilters.length > 0 && selectedLeftData) {
// 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨)
const linkedFilter = linkedFilters[0];
fkSourceColumn = linkedFilter.sourceColumn;
// targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리
// 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no"
const targetColumnParts = linkedFilter.targetColumn.split(".");
if (targetColumnParts.length === 2) {
linkedFilterTargetTable = targetColumnParts[0];
fkTargetColumn = targetColumnParts[1];
} else {
fkTargetColumn = linkedFilter.targetColumn;
}
}
// 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블
const effectiveTargetTable = targetTable || linkedFilterTargetTable;
// 🆕 DB에서 컬럼 정보 로드 (webType 등)
useEffect(() => {
const loadColumnInfo = async () => {
if (!effectiveTargetTable) return;
try {
const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data);
// 응답 구조에 따라 데이터 추출
// 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } }
let columns: any[] = [];
if (response.data?.success && response.data?.data) {
// data.columns가 배열인 경우 (실제 응답 구조)
if (Array.isArray(response.data.data.columns)) {
columns = response.data.data.columns;
}
// data가 배열인 경우
else if (Array.isArray(response.data.data)) {
columns = response.data.data;
}
// data 자체가 객체이고 배열이 아닌 경우 (키-값 형태)
else if (typeof response.data.data === "object") {
columns = Object.values(response.data.data);
}
}
// success 없이 바로 배열인 경우
else if (Array.isArray(response.data)) {
columns = response.data;
}
console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개");
if (columns.length > 0) {
const colMap: Record<string, any> = {};
columns.forEach((col: any) => {
// columnName 또는 column_name 또는 name 키 사용
const colName = col.columnName || col.column_name || col.name;
if (colName) {
colMap[colName] = col;
}
});
setColumnInfo(colMap);
console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", {
table: effectiveTargetTable,
columns: Object.keys(colMap),
webTypes: Object.entries(colMap).map(
([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`,
),
});
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error);
}
};
loadColumnInfo();
}, [effectiveTargetTable]);
// linkedFilters가 없으면 config에서 가져오기
const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn;
const fkValue =
fkSourceColumn && selectedLeftData
? selectedLeftData[fkSourceColumn]
: fkColumn && selectedLeftData
? selectedLeftData[fkColumn]
: null;
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
fieldName,
hasFormData: !!formData,
formDataId: formData?.id,
formDataValue: formData?.[fieldName],
propsValue: value,
@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
groupByColumn,
groupKeyValue,
targetTable,
linkedFilterTargetTable,
effectiveTargetTable,
hasGroupedData: groupedData !== null,
groupedDataLength: groupedData?.length,
// 🆕 분할 패널 관련 정보
linkedFiltersCount: linkedFilters.length,
linkedFilters: linkedFilters.map((f) => `${f.sourceColumn}${f.targetColumn}`),
fkSourceColumn,
fkTargetColumn,
splitPanelPosition,
isRightPanel,
hasSelectedLeftData: !!selectedLeftData,
// 🆕 selectedLeftData 상세 정보 (디버깅용)
selectedLeftDataId: selectedLeftData?.id,
selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A",
selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null,
fkColumn,
fkValue,
});
// 🆕 수정 모드에서 그룹화된 데이터 로드
@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 이미 로드했거나 조건이 맞지 않으면 스킵
if (groupDataLoadedRef.current) return;
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
groupByColumn,
groupKeyValue,
targetTable,
});
setIsLoadingGroupData(true);
groupDataLoadedRef.current = true;
try {
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
// search 파라미터 사용 (filters가 아닌 search)
@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
size: 100, // 충분히 큰 값
search: { [groupByColumn]: groupKeyValue },
});
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
success: response.data?.success,
hasData: !!response.data?.data,
dataType: typeof response.data?.data,
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
});
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
if (response.data?.success && response.data?.data?.data) {
const items = response.data.data.data; // 실제 데이터 배열
@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
firstItem: items[0],
});
setGroupedData(items);
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean);
setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용)
if (splitPanelContext?.addItemIds && itemIds.length > 0) {
splitPanelContext.addItemIds(itemIds);
}
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
setIsLoadingGroupData(false);
}
};
loadGroupedData();
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
// 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드
// 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링
const prevFkValueRef = useRef<string | null>(null);
useEffect(() => {
const loadDataByFK = async () => {
// 우측 패널이 아니면 스킵
if (!isRightPanel) {
return;
}
// 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화
if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) {
console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", {
fkColumn,
fkValue,
prevFkValue: prevFkValueRef.current,
});
// 이전에 데이터가 있었다면 초기화
if (prevFkValueRef.current !== null) {
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
prevFkValueRef.current = null;
}
return;
}
// FK 컬럼이나 타겟 테이블이 없으면 스킵
if (!fkColumn || !effectiveTargetTable) {
console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", {
fkColumn,
effectiveTargetTable,
});
return;
}
// 같은 FK 값으로 이미 로드했으면 스킵
const currentFkValueStr = String(fkValue);
if (prevFkValueRef.current === currentFkValueStr) {
console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr);
return;
}
prevFkValueRef.current = currentFkValueStr;
console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", {
fkColumn,
fkValue,
effectiveTargetTable,
});
setIsLoadingGroupData(true);
try {
// API 호출: FK 값을 기준으로 데이터 조회
const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, {
page: 1,
size: 100,
search: { [fkColumn]: fkValue },
});
if (response.data?.success) {
const items = response.data?.data?.data || [];
console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", {
count: items.length,
fkColumn,
fkValue,
effectiveTargetTable,
});
// 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정)
setGroupedData(items);
// 원본 데이터 ID 목록 저장
const itemIds = items.map((item: any) => String(item.id)).filter(Boolean);
setOriginalItemIds(itemIds);
// onChange 호출 (effectiveTargetTable 사용)
if (onChange) {
if (items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: effectiveTargetTable,
_existingRecord: !!item.id,
}));
onChange(dataWithMeta);
} else {
// 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어)
console.log(" [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화");
onChange([]);
}
}
} else {
// API 실패 시 빈 배열로 설정
console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화");
setGroupedData([]);
setOriginalItemIds([]);
onChange?.([]);
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error);
setGroupedData([]);
} finally {
setIsLoadingGroupData(false);
}
};
loadDataByFK();
}, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]);
// 값이 JSON 문자열인 경우 파싱
let parsedValue: any[] = [];
// 🆕 그룹화된 데이터가 있으면 우선 사용
if (groupedData !== null && groupedData.length > 0) {
// 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!)
// groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용
if (groupedData !== null) {
parsedValue = groupedData;
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
// 그룹화 설정이 없는 경우에만 단일 행 사용
@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 데이터 수신 핸들러
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
if (!data || data.length === 0) {
toast.warning("전달할 데이터가 없습니다");
return;
@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해)
const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']);
const systemFields = new Set([
"_targetTable",
"_isNewItem",
"created_date",
"updated_date",
"writer",
"company_code",
]);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach(key => {
Object.keys(item).forEach((key) => {
// 🆕 id 필드는 제외 (새 레코드로 저장되도록)
if (key === 'id') {
if (key === "id") {
return; // id 필드 제외
}
// 정의된 필드이거나 시스템 필드인 경우만 포함
@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
let newItems: any[];
let addedCount = 0;
let duplicateCount = 0;
if (mode === "replace") {
newItems = filteredData;
addedCount = filteredData.length;
} else {
// 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음)
const existingItemCodes = new Set(
currentValue
.map((item: any) => item.item_code)
.filter(Boolean)
);
const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean));
const uniqueNewItems = filteredData.filter((item: any) => {
const itemCode = item.item_code;
if (itemCode && existingItemCodes.has(itemCode)) {
@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
}
return true;
});
newItems = [...currentValue, ...uniqueNewItems];
addedCount = uniqueNewItems.length;
}
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
currentValue,
newItems,
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", {
currentValue,
newItems,
mode,
addedCount,
duplicateCount,
@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용)
// item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음)
if (splitPanelContext?.addItemIds && addedCount > 0) {
const newItemCodes = newItems
.map((item: any) => String(item.item_code))
.filter(Boolean);
const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean);
splitPanelContext.addItemIds(newItemCodes);
}
// JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newItems);
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
jsonValue,
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
jsonValue,
hasOnChange: !!onChangeRef.current,
hasOnFormDataChange: !!onFormDataChangeRef.current,
fieldName: fieldNameRef.current,
});
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
if (onFormDataChangeRef.current) {
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
}, []);
// DataReceivable 인터페이스 구현
const dataReceiver = useMemo<DataReceivable>(() => ({
componentId: component.id,
componentType: "repeater-field-group",
receiveData: handleReceiveData,
}), [component.id, handleReceiveData]);
const dataReceiver = useMemo<DataReceivable>(
() => ({
componentId: component.id,
componentType: "repeater-field-group",
receiveData: handleReceiveData,
}),
[component.id, handleReceiveData],
);
// ScreenContext에 데이터 수신자로 등록
useEffect(() => {
if (screenContext && component.id) {
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataReceiver(component.id);
};
@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
useEffect(() => {
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
componentId: component.id,
position: splitPanelPosition,
});
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
receiverRef.current = dataReceiver;
return () => {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
useEffect(() => {
const handleSplitPanelDataTransfer = (event: CustomEvent) => {
const { data, mode, mappingRules } = event.detail;
console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", {
dataCount: data?.length,
mode,
componentId: component.id,
});
// 우측 패널의 리피터 필드 그룹만 데이터를 수신
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelPosition === "right" && data && data.length > 0) {
@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
};
window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
return () => {
window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener);
};
}, [screenContext?.splitPanelPosition, handleReceiveData, component.id]);
// 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화
const handleRepeaterChange = useCallback((newValue: any[]) => {
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newValue);
onChange?.(jsonValue);
// 🆕 groupedData 상태도 업데이트
setGroupedData(newValue);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
// 현재 항목들의 ID 목록
const currentIds = newValue
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
splitPanelContext.removeItemIds(removedIds);
const handleRepeaterChange = useCallback(
(newValue: any[]) => {
// 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가
let valueWithMeta = newValue;
if (isRightPanel && effectiveTargetTable) {
valueWithMeta = newValue.map((item: any) => {
const itemWithMeta = {
...item,
_targetTable: effectiveTargetTable,
};
// 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가
if (fkColumn && fkValue && item._isNewItem) {
itemWithMeta[fkColumn] = fkValue;
console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", {
fkColumn,
fkValue,
});
}
return itemWithMeta;
});
}
// 새로 추가된 ID가 있으면 등록
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
if (newIds.length > 0) {
console.log(" [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
splitPanelContext.addItemIds(newIds);
// 배열을 JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(valueWithMeta);
console.log("📤 [RepeaterFieldGroup] 데이터 변경:", {
fieldName,
itemCount: valueWithMeta.length,
isRightPanel,
hasScreenContextUpdateFormData: !!screenContext?.updateFormData,
});
// 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용
// (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴)
if (isRightPanel && screenContext?.updateFormData) {
screenContext.updateFormData(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName });
} else {
// 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용
onChange?.(jsonValue);
if (onFormDataChange) {
onFormDataChange(fieldName, jsonValue);
console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName });
}
}
}
}, [onChange, splitPanelContext, screenContext?.splitPanelPosition]);
// 🆕 groupedData 상태도 업데이트
setGroupedData(valueWithMeta);
// 🆕 SplitPanelContext의 addedItemIds 동기화
if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") {
// 현재 항목들의 ID 목록
const currentIds = newValue
.map((item: any) => String(item.id || item.po_item_id || item.item_id))
.filter(Boolean);
// 기존 addedItemIds와 비교하여 삭제된 ID 찾기
const addedIds = splitPanelContext.addedItemIds;
const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id));
if (removedIds.length > 0) {
console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds);
splitPanelContext.removeItemIds(removedIds);
}
// 새로 추가된 ID가 있으면 등록
const newIds = currentIds.filter((id: string) => !addedIds.has(id));
if (newIds.length > 0) {
console.log(" [RepeaterFieldGroup] 새 항목 ID 추가:", newIds);
splitPanelContext.addItemIds(newIds);
}
}
},
[
onChange,
onFormDataChange,
splitPanelContext,
screenContext?.splitPanelPosition,
screenContext?.updateFormData,
isRightPanel,
effectiveTargetTable,
fkColumn,
fkValue,
fieldName,
],
);
// 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함)
const effectiveConfig = {
...config,
targetTable: effectiveTargetTable || config.targetTable,
};
return (
<RepeaterInput
value={parsedValue}
onChange={handleRepeaterChange}
config={config}
config={effectiveConfig}
disabled={disabled}
readonly={readonly}
menuObjid={menuObjid}

View File

@ -0,0 +1,400 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useRef, useMemo } from "react";
/**
* SplitPanelResize Context
* ( ) Context
*
* 주의: contexts/SplitPanelContext.tsx는 Context이고,
* Context는 Context입니다.
*/
/**
* ( )
*/
export interface SplitPanelInfo {
id: string;
// 분할 패널의 좌표 (스크린 캔버스 기준, px)
x: number;
y: number;
width: number;
height: number;
// 좌측 패널 비율 (0-100)
leftWidthPercent: number;
// 초기 좌측 패널 비율 (드래그 시작 시점)
initialLeftWidthPercent: number;
// 드래그 중 여부
isDragging: boolean;
}
export interface SplitPanelResizeContextValue {
// 등록된 분할 패널들
splitPanels: Map<string, SplitPanelInfo>;
// 분할 패널 등록/해제/업데이트
registerSplitPanel: (id: string, info: Omit<SplitPanelInfo, "id">) => void;
unregisterSplitPanel: (id: string) => void;
updateSplitPanel: (id: string, updates: Partial<SplitPanelInfo>) => void;
// 컴포넌트가 어떤 분할 패널의 좌측 영역 위에 있는지 확인
// 반환값: { panelId, offsetX } 또는 null
getOverlappingSplitPanel: (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null;
// 컴포넌트의 조정된 X 좌표 계산
// 분할 패널 좌측 영역 위에 있으면, 드래그에 따라 조정된 X 좌표 반환
getAdjustedX: (componentX: number, componentY: number, componentWidth: number, componentHeight: number) => number;
// 레거시 호환 (단일 분할 패널용)
leftWidthPercent: number;
containerRect: DOMRect | null;
dividerX: number;
isDragging: boolean;
splitPanelId: string | null;
updateLeftWidth: (percent: number) => void;
updateContainerRect: (rect: DOMRect | null) => void;
updateDragging: (dragging: boolean) => void;
}
// Context 생성
const SplitPanelResizeContext = createContext<SplitPanelResizeContextValue | null>(null);
/**
* SplitPanelResize Context Provider
*
*/
export const SplitPanelProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
// 등록된 분할 패널들
const splitPanelsRef = useRef<Map<string, SplitPanelInfo>>(new Map());
const [, forceUpdate] = useState(0);
// 레거시 호환용 상태
const [legacyLeftWidthPercent, setLegacyLeftWidthPercent] = useState(30);
const [legacyContainerRect, setLegacyContainerRect] = useState<DOMRect | null>(null);
const [legacyIsDragging, setLegacyIsDragging] = useState(false);
const [legacySplitPanelId, setLegacySplitPanelId] = useState<string | null>(null);
// 분할 패널 등록
const registerSplitPanel = useCallback((id: string, info: Omit<SplitPanelInfo, "id">) => {
splitPanelsRef.current.set(id, { id, ...info });
setLegacySplitPanelId(id);
setLegacyLeftWidthPercent(info.leftWidthPercent);
forceUpdate((n) => n + 1);
}, []);
// 분할 패널 해제
const unregisterSplitPanel = useCallback(
(id: string) => {
splitPanelsRef.current.delete(id);
if (legacySplitPanelId === id) {
setLegacySplitPanelId(null);
}
forceUpdate((n) => n + 1);
},
[legacySplitPanelId],
);
// 분할 패널 업데이트
const updateSplitPanel = useCallback((id: string, updates: Partial<SplitPanelInfo>) => {
const panel = splitPanelsRef.current.get(id);
if (panel) {
const updatedPanel = { ...panel, ...updates };
splitPanelsRef.current.set(id, updatedPanel);
// 레거시 호환 상태 업데이트
if (updates.leftWidthPercent !== undefined) {
setLegacyLeftWidthPercent(updates.leftWidthPercent);
}
if (updates.isDragging !== undefined) {
setLegacyIsDragging(updates.isDragging);
}
forceUpdate((n) => n + 1);
}
}, []);
/**
*
*/
const getOverlappingSplitPanel = useCallback(
(
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
): { panelId: string; panel: SplitPanelInfo; isInLeftPanel: boolean } | null => {
for (const [panelId, panel] of splitPanelsRef.current) {
// 컴포넌트의 중심점
const componentCenterX = componentX + componentWidth / 2;
const componentCenterY = componentY + componentHeight / 2;
// 컴포넌트가 분할 패널 영역 내에 있는지 확인
const isInPanelX = componentCenterX >= panel.x && componentCenterX <= panel.x + panel.width;
const isInPanelY = componentCenterY >= panel.y && componentCenterY <= panel.y + panel.height;
if (isInPanelX && isInPanelY) {
// 좌측 패널의 현재 너비 (px)
const leftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 좌측 패널 경계 (분할 패널 기준 상대 좌표)
const dividerX = panel.x + leftPanelWidth;
// 컴포넌트 중심이 좌측 패널 내에 있는지 확인
const isInLeftPanel = componentCenterX < dividerX;
return { panelId, panel, isInLeftPanel };
}
}
return null;
},
[],
);
/**
* X
* , X
*
* :
* - X
* - , X
*/
const getAdjustedX = useCallback(
(componentX: number, componentY: number, componentWidth: number, componentHeight: number): number => {
const overlap = getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
if (!overlap || !overlap.isInLeftPanel) {
// 분할 패널 위에 없거나, 우측 패널 위에 있으면 원래 위치 유지
return componentX;
}
const { panel } = overlap;
// 초기 좌측 패널 너비 (설정된 splitRatio 기준)
const initialLeftPanelWidth = (panel.width * panel.initialLeftWidthPercent) / 100;
// 현재 좌측 패널 너비 (드래그로 변경된 값)
const currentLeftPanelWidth = (panel.width * panel.leftWidthPercent) / 100;
// 변화가 없으면 원래 위치 반환
if (Math.abs(initialLeftPanelWidth - currentLeftPanelWidth) < 1) {
return componentX;
}
// 컴포넌트의 분할 패널 내 상대 X 좌표
const relativeX = componentX - panel.x;
// 좌측 패널 내에서의 비율 (0~1)
const ratioInLeftPanel = relativeX / initialLeftPanelWidth;
// 조정된 상대 X 좌표 = 원래 비율 * 현재 좌측 패널 너비
const adjustedRelativeX = ratioInLeftPanel * currentLeftPanelWidth;
// 절대 X 좌표로 변환
const adjustedX = panel.x + adjustedRelativeX;
console.log("📍 [SplitPanel] 버튼 위치 조정:", {
componentX,
panelX: panel.x,
relativeX,
initialLeftPanelWidth,
currentLeftPanelWidth,
ratioInLeftPanel,
adjustedX,
delta: adjustedX - componentX,
});
return adjustedX;
},
[getOverlappingSplitPanel],
);
// 레거시 호환 - dividerX 계산
const legacyDividerX = legacyContainerRect ? (legacyContainerRect.width * legacyLeftWidthPercent) / 100 : 0;
// 레거시 호환 함수들
const updateLeftWidth = useCallback((percent: number) => {
setLegacyLeftWidthPercent(percent);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
splitPanelsRef.current.set(firstPanelId, { ...panel, leftWidthPercent: percent });
}
}
forceUpdate((n) => n + 1);
}, []);
const updateContainerRect = useCallback((rect: DOMRect | null) => {
setLegacyContainerRect(rect);
}, []);
const updateDragging = useCallback((dragging: boolean) => {
setLegacyIsDragging(dragging);
// 첫 번째 분할 패널 업데이트
const firstPanelId = splitPanelsRef.current.keys().next().value;
if (firstPanelId) {
const panel = splitPanelsRef.current.get(firstPanelId);
if (panel) {
// 드래그 시작 시 초기 비율 저장
const updates: Partial<SplitPanelInfo> = { isDragging: dragging };
if (dragging) {
updates.initialLeftWidthPercent = panel.leftWidthPercent;
}
splitPanelsRef.current.set(firstPanelId, { ...panel, ...updates });
}
}
forceUpdate((n) => n + 1);
}, []);
const value = useMemo<SplitPanelResizeContextValue>(
() => ({
splitPanels: splitPanelsRef.current,
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
// 레거시 호환
leftWidthPercent: legacyLeftWidthPercent,
containerRect: legacyContainerRect,
dividerX: legacyDividerX,
isDragging: legacyIsDragging,
splitPanelId: legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
}),
[
registerSplitPanel,
unregisterSplitPanel,
updateSplitPanel,
getOverlappingSplitPanel,
getAdjustedX,
legacyLeftWidthPercent,
legacyContainerRect,
legacyDividerX,
legacyIsDragging,
legacySplitPanelId,
updateLeftWidth,
updateContainerRect,
updateDragging,
],
);
return <SplitPanelResizeContext.Provider value={value}>{children}</SplitPanelResizeContext.Provider>;
};
/**
* SplitPanelResize Context
* .
*/
export const useSplitPanel = (): SplitPanelResizeContextValue => {
const context = useContext(SplitPanelResizeContext);
// Context가 없으면 기본값 반환 (Provider 외부에서 사용 시)
if (!context) {
return {
splitPanels: new Map(),
registerSplitPanel: () => {},
unregisterSplitPanel: () => {},
updateSplitPanel: () => {},
getOverlappingSplitPanel: () => null,
getAdjustedX: (x) => x,
leftWidthPercent: 30,
containerRect: null,
dividerX: 0,
isDragging: false,
splitPanelId: null,
updateLeftWidth: () => {},
updateContainerRect: () => {},
updateDragging: () => {},
};
}
return context;
};
/**
*
* , X
*
* @param componentX - X (px)
* @param componentY - Y (px)
* @param componentWidth - (px)
* @param componentHeight - (px)
* @returns X
*/
export const useAdjustedComponentPosition = (
componentX: number,
componentY: number,
componentWidth: number,
componentHeight: number,
) => {
const context = useSplitPanel();
const adjustedX = context.getAdjustedX(componentX, componentY, componentWidth, componentHeight);
const overlap = context.getOverlappingSplitPanel(componentX, componentY, componentWidth, componentHeight);
return {
adjustedX,
isInSplitPanel: !!overlap,
isInLeftPanel: overlap?.isInLeftPanel ?? false,
isDragging: overlap?.panel.isDragging ?? false,
panelId: overlap?.panelId ?? null,
};
};
/**
* ( )
*/
export const useAdjustedPosition = (originalXPercent: number) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const isInLeftPanel = originalXPercent <= leftWidthPercent;
const adjustedXPercent = isInLeftPanel ? (originalXPercent / 100) * leftWidthPercent : originalXPercent;
const adjustedXPx = containerRect ? (containerRect.width * adjustedXPercent) / 100 : 0;
return {
adjustedXPercent,
adjustedXPx,
isInLeftPanel,
isDragging,
dividerX,
containerRect,
leftWidthPercent,
};
};
/**
* , ( )
*/
export const useSplitPanelAwarePosition = (
initialLeftPercent: number,
options?: {
followDivider?: boolean;
offset?: number;
},
) => {
const { leftWidthPercent, containerRect, dividerX, isDragging } = useSplitPanel();
const { followDivider = false, offset = 0 } = options || {};
if (followDivider) {
return {
left: containerRect ? `${dividerX + offset}px` : `${leftWidthPercent}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
}
const adjustedLeft = (initialLeftPercent / 100) * leftWidthPercent;
return {
left: `${adjustedLeft}%`,
transition: isDragging ? "none" : "left 0.15s ease-out",
};
};
export default SplitPanelResizeContext;

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useCallback, useEffect, useMemo } from "react";
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { ComponentRendererProps } from "../../types";
import { SplitPanelLayoutConfig } from "./types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -34,8 +34,9 @@ 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";
import { useSplitPanel } from "./SplitPanelContext";
export interface SplitPanelLayoutComponentProps extends ComponentRendererProps {
// 추가 props
@ -73,12 +74,81 @@ 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];
}
// 🆕 2-1⃣ item_id 패턴 시도 (백엔드가 item_id_xxx 형식으로 반환하는 경우)
// 예: item_info.item_name → item_id_item_name
const idPatternKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
if (item[idPatternKey] !== undefined) {
return item[idPatternKey];
}
// 3⃣ 별칭 패턴: 소스컬럼_name (기본 표시 컬럼용)
// 예: item_code_name (item_name의 별칭)
if (fieldName === "item_name" || fieldName === "name") {
const aliasKey = `${inferredSourceColumn}_name`;
if (item[aliasKey] !== undefined) {
return item[aliasKey];
}
// 🆕 item_id_name 패턴도 시도
const idAliasKey = `${tableName.replace("_info", "_id").replace("_mng", "_id")}_name`;
if (item[idAliasKey] !== undefined) {
return item[idAliasKey];
}
}
// 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 +195,202 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const [leftWidth, setLeftWidth] = useState(splitRatio);
const containerRef = React.useRef<HTMLDivElement>(null);
// 🆕 SplitPanel Resize Context 연동 (버튼 등 외부 컴포넌트와 드래그 리사이즈 상태 공유)
const splitPanelContext = useSplitPanel();
const {
registerSplitPanel: ctxRegisterSplitPanel,
unregisterSplitPanel: ctxUnregisterSplitPanel,
updateSplitPanel: ctxUpdateSplitPanel,
} = splitPanelContext;
const splitPanelId = `split-panel-${component.id}`;
// 디버깅: Context 연결 상태 확인
console.log("🔗 [SplitPanelLayout] Context 연결 상태:", {
componentId: component.id,
splitPanelId,
hasRegisterFunc: typeof ctxRegisterSplitPanel === "function",
splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음",
});
// Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행
const ctxRegisterRef = useRef(ctxRegisterSplitPanel);
const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel);
ctxRegisterRef.current = ctxRegisterSplitPanel;
ctxUnregisterRef.current = ctxUnregisterSplitPanel;
useEffect(() => {
// 컴포넌트의 위치와 크기 정보
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
const panelInfo = {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
leftWidthPercent: splitRatio, // 초기값은 splitRatio 사용
initialLeftWidthPercent: splitRatio,
isDragging: false,
};
console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", {
splitPanelId,
panelInfo,
});
ctxRegisterRef.current(splitPanelId, panelInfo);
return () => {
console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId);
ctxUnregisterRef.current(splitPanelId);
};
// 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [splitPanelId]);
// 위치/크기 변경 시 Context 업데이트 (등록 후)
const ctxUpdateRef = useRef(ctxUpdateSplitPanel);
ctxUpdateRef.current = ctxUpdateSplitPanel;
useEffect(() => {
const panelX = component.position?.x || 0;
const panelY = component.position?.y || 0;
const panelWidth = component.size?.width || component.style?.width || 800;
const panelHeight = component.size?.height || component.style?.height || 600;
ctxUpdateRef.current(splitPanelId, {
x: panelX,
y: panelY,
width: typeof panelWidth === "number" ? panelWidth : parseInt(String(panelWidth)) || 800,
height: typeof panelHeight === "number" ? panelHeight : parseInt(String(panelHeight)) || 600,
});
}, [
splitPanelId,
component.position?.x,
component.position?.y,
component.size?.width,
component.size?.height,
component.style?.width,
component.style?.height,
]);
// leftWidth 변경 시 Context 업데이트
useEffect(() => {
ctxUpdateRef.current(splitPanelId, { leftWidthPercent: leftWidth });
}, [leftWidth, splitPanelId]);
// 드래그 상태 변경 시 Context 업데이트
// 이전 드래그 상태를 추적하여 드래그 종료 시점을 감지
const prevIsDraggingRef = useRef(false);
useEffect(() => {
const wasJustDragging = prevIsDraggingRef.current && !isDragging;
if (isDragging) {
// 드래그 시작 시: 현재 비율을 초기 비율로 저장
ctxUpdateRef.current(splitPanelId, {
isDragging: true,
initialLeftWidthPercent: leftWidth,
});
} else if (wasJustDragging) {
// 드래그 종료 시: 최종 비율을 초기 비율로 업데이트 (버튼 위치 고정)
ctxUpdateRef.current(splitPanelId, {
isDragging: false,
initialLeftWidthPercent: leftWidth,
});
console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", {
splitPanelId,
finalLeftWidthPercent: leftWidth,
});
}
prevIsDraggingRef.current = isDragging;
}, [isDragging, splitPanelId, leftWidth]);
// 🆕 그룹별 합산된 데이터 계산
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 +699,81 @@ 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 또는 warehouse_info → warehouse_id)
// 기본: _info → _code, 백업: _info → _id
const primarySourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id");
const secondarySourceColumn = refTable.replace("_info", "_id").replace("_mng", "_id");
// 실제 존재하는 소스 컬럼은 백엔드에서 결정 (프론트엔드는 두 패턴 모두 전달)
const inferredSourceColumn = primarySourceColumn;
// 이미 추가된 조인인지 확인 (동일 테이블, 동일 소스컬럼)
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 +799,8 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
}
}, [
componentConfig.leftPanel?.tableName,
componentConfig.leftPanel?.columns,
componentConfig.leftPanel?.dataFilter,
componentConfig.rightPanel?.relation?.leftColumn,
isDesignMode,
toast,
@ -502,6 +837,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 +1039,39 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
const uniqueValues = new Set<string>();
leftData.forEach((item) => {
const value = item[columnName];
// 🆕 조인 컬럼 처리 (item_info.standard → item_code_standard 또는 item_id_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_id 패턴 시도
if (value === undefined) {
const idPatternKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_${fieldName}`;
value = item[idPatternKey];
}
// 기본 별칭 패턴 시도 (item_code_name 또는 item_id_name)
if (value === undefined && (fieldName === "item_name" || fieldName === "name")) {
const aliasKey = `${inferredSourceColumn}_name`;
value = item[aliasKey];
// item_id_name 패턴도 시도
if (value === undefined) {
const idAliasKey = `${refTable.replace("_info", "_id").replace("_mng", "_id")}_name`;
value = item[idAliasKey];
}
}
} else {
// 일반 컬럼
value = item[columnName];
}
if (value !== null && value !== undefined && value !== "") {
// _name 필드 우선 사용 (category/entity type)
const displayValue = item[`${columnName}_name`] || value;
@ -666,6 +1095,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 +1121,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 +1134,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
onColumnVisibilityChange: setLeftColumnVisibility,
onColumnOrderChange: setLeftColumnOrder, // 🔧 컬럼 순서 변경 콜백 추가
getColumnUniqueValues: getLeftColumnUniqueValues, // 🔧 고유값 가져오기 함수 추가
onGroupSumChange: setLeftGroupSumConfig, // 🆕 그룹별 합산 설정 콜백
});
return () => unregisterTable(leftTableId);
@ -1651,16 +2091,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 +2186,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
>
{formatCellValue(
col.name,
item[col.name],
getEntityJoinValue(item, col.name),
leftCategoryMappings,
col.format,
)}
@ -1796,7 +2245,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 +2305,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 +2571,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 +2643,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 +2665,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 +2744,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

@ -58,3 +58,13 @@ export type { SplitPanelLayoutConfig } from "./types";
// 컴포넌트 내보내기
export { SplitPanelLayoutComponent } from "./SplitPanelLayoutComponent";
export { SplitPanelLayoutRenderer } from "./SplitPanelLayoutRenderer";
// Resize Context 내보내기 (버튼 등 외부 컴포넌트에서 분할 패널 드래그 리사이즈 상태 활용)
export {
SplitPanelProvider,
useSplitPanel,
useAdjustedPosition,
useSplitPanelAwarePosition,
useAdjustedComponentPosition,
} from "./SplitPanelContext";
export type { SplitPanelResizeContextValue, SplitPanelInfo } from "./SplitPanelContext";

View File

@ -224,6 +224,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
isInModal: _isInModal,
isPreview: _isPreview,
originalData: _originalData,
_originalData: __originalData,
_initialData: __initialData,
_groupedData: __groupedData,
allComponents: _allComponents,
selectedRows: _selectedRows,
selectedRowsData: _selectedRowsData,

View File

@ -126,11 +126,13 @@ export function UniversalFormModalComponent({
initialData: propInitialData,
// DynamicComponentRenderer에서 전달되는 props (DOM 전달 방지를 위해 _ prefix 사용)
_initialData,
_originalData,
_groupedData,
onSave,
onCancel,
onChange,
...restProps // 나머지 props는 DOM에 전달하지 않음
}: UniversalFormModalComponentProps & { _initialData?: any }) {
}: UniversalFormModalComponentProps & { _initialData?: any; _originalData?: any; _groupedData?: any }) {
// initialData 우선순위: 직접 전달된 prop > DynamicComponentRenderer에서 전달된 prop
const initialData = propInitialData || _initialData;
// 설정 병합
@ -198,29 +200,49 @@ export function UniversalFormModalComponent({
// 초기 데이터를 한 번만 캡처 (컴포넌트 마운트 시)
const capturedInitialData = useRef<Record<string, any> | undefined>(undefined);
const hasInitialized = useRef(false);
// 마지막으로 초기화된 데이터의 ID를 추적 (수정 모달에서 다른 항목 선택 시 재초기화 필요)
const lastInitializedId = useRef<string | undefined>(undefined);
// 초기화 - 최초 마운트 시에만 실행
// 초기화 - 최초 마운트 시 또는 initialData의 ID가 변경되었을 때 실행
useEffect(() => {
// 이미 초기화되었으면 스킵
if (hasInitialized.current) {
// initialData에서 ID 값 추출 (id, ID, objid 등)
const currentId = initialData?.id || initialData?.ID || initialData?.objid;
const currentIdString = currentId !== undefined ? String(currentId) : undefined;
// 이미 초기화되었고, ID가 동일하면 스킵
if (hasInitialized.current && lastInitializedId.current === currentIdString) {
return;
}
// 🆕 수정 모드: initialData에 데이터가 있으면서 ID가 변경된 경우 재초기화
if (hasInitialized.current && currentIdString && lastInitializedId.current !== currentIdString) {
console.log("[UniversalFormModal] ID 변경 감지 - 재초기화:", {
prevId: lastInitializedId.current,
newId: currentIdString,
initialData: initialData,
});
// 채번 플래그 초기화 (새 항목이므로)
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
}
// 최초 initialData 캡처 (이후 변경되어도 이 값 사용)
if (initialData && Object.keys(initialData).length > 0) {
capturedInitialData.current = JSON.parse(JSON.stringify(initialData)); // 깊은 복사
lastInitializedId.current = currentIdString;
console.log("[UniversalFormModal] 초기 데이터 캡처:", capturedInitialData.current);
}
hasInitialized.current = true;
initializeForm();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 의존성 배열 - 마운트 시 한 번만 실행
}, [initialData?.id, initialData?.ID, initialData?.objid]); // ID 값 변경 시 재초기화
// config 변경 시에만 재초기화 (initialData 변경은 무시) - 채번규칙 제외
useEffect(() => {
if (!hasInitialized.current) return; // 최초 초기화 전이면 스킵
console.log('[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)');
console.log("[useEffect config 변경] 재초기화 스킵 (채번 중복 방지)");
// initializeForm(); // 주석 처리 - config 변경 시 재초기화 안 함 (채번 중복 방지)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
@ -228,7 +250,7 @@ export function UniversalFormModalComponent({
// 컴포넌트 unmount 시 채번 플래그 초기화
useEffect(() => {
return () => {
console.log('[채번] 컴포넌트 unmount - 플래그 초기화');
console.log("[채번] 컴포넌트 unmount - 플래그 초기화");
numberingGeneratedRef.current = false;
isGeneratingRef.current = false;
};
@ -239,7 +261,7 @@ export function UniversalFormModalComponent({
useEffect(() => {
const handleBeforeFormSave = (event: Event) => {
if (!(event instanceof CustomEvent) || !event.detail?.formData) return;
// 설정에 정의된 필드 columnName 목록 수집
const configuredFields = new Set<string>();
config.sections.forEach((section) => {
@ -249,10 +271,10 @@ export function UniversalFormModalComponent({
}
});
});
console.log("[UniversalFormModal] beforeFormSave 이벤트 수신");
console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields));
// UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함)
// 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀
// (UniversalFormModal이 해당 필드의 주인이므로)
@ -260,7 +282,7 @@ export function UniversalFormModalComponent({
// 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합
const isConfiguredField = configuredFields.has(key);
const isNumberingRuleId = key.endsWith("_numberingRuleId");
if (isConfiguredField || isNumberingRuleId) {
if (value !== undefined && value !== null && value !== "") {
event.detail.formData[key] = value;
@ -268,7 +290,7 @@ export function UniversalFormModalComponent({
}
}
}
// 반복 섹션 데이터도 병합 (필요한 경우)
if (Object.keys(repeatSections).length > 0) {
for (const [sectionId, items] of Object.entries(repeatSections)) {
@ -278,9 +300,9 @@ export function UniversalFormModalComponent({
}
}
};
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
@ -314,11 +336,18 @@ export function UniversalFormModalComponent({
// 폼 초기화
const initializeForm = useCallback(async () => {
console.log('[initializeForm] 시작');
console.log("[initializeForm] 시작");
// 캡처된 initialData 사용 (props로 전달된 initialData가 아닌)
const effectiveInitialData = capturedInitialData.current || initialData;
console.log("[initializeForm] 초기 데이터:", {
capturedInitialData: capturedInitialData.current,
initialData: initialData,
effectiveInitialData: effectiveInitialData,
hasData: effectiveInitialData && Object.keys(effectiveInitialData).length > 0,
});
const newFormData: FormDataState = {};
const newRepeatSections: { [sectionId: string]: RepeatSectionItem[] } = {};
const newCollapsed = new Set<string>();
@ -366,9 +395,9 @@ export function UniversalFormModalComponent({
setOriginalData(effectiveInitialData || {});
// 채번규칙 자동 생성
console.log('[initializeForm] generateNumberingValues 호출');
console.log("[initializeForm] generateNumberingValues 호출");
await generateNumberingValues(newFormData);
console.log('[initializeForm] 완료');
console.log("[initializeForm] 완료");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]); // initialData는 의존성에서 제거 (capturedInitialData.current 사용)
@ -389,23 +418,23 @@ export function UniversalFormModalComponent({
// 채번규칙 자동 생성 (중복 호출 방지)
const numberingGeneratedRef = useRef(false);
const isGeneratingRef = useRef(false); // 진행 중 플래그 추가
const generateNumberingValues = useCallback(
async (currentFormData: FormDataState) => {
// 이미 생성되었거나 진행 중이면 스킵
if (numberingGeneratedRef.current) {
console.log('[채번] 이미 생성됨 - 스킵');
console.log("[채번] 이미 생성됨 - 스킵");
return;
}
if (isGeneratingRef.current) {
console.log('[채번] 생성 진행 중 - 스킵');
console.log("[채번] 생성 진행 중 - 스킵");
return;
}
isGeneratingRef.current = true; // 진행 중 표시
console.log('[채번] 미리보기 생성 시작');
console.log("[채번] 생성 시작");
const updatedData = { ...currentFormData };
let hasChanges = false;
@ -425,21 +454,23 @@ export function UniversalFormModalComponent({
const response = await previewNumberingCode(field.numberingRule.ruleId);
if (response.success && response.data?.generatedCode) {
updatedData[field.columnName] = response.data.generatedCode;
// 저장 시 실제 할당을 위해 ruleId 저장 (TextInput과 동일한 키 형식)
const ruleIdKey = `${field.columnName}_numberingRuleId`;
updatedData[ruleIdKey] = field.numberingRule.ruleId;
hasChanges = true;
numberingGeneratedRef.current = true; // 생성 완료 표시
console.log(`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`);
console.log(
`[채번 미리보기 완료] ${field.columnName} = ${response.data.generatedCode} (저장 시 실제 할당)`,
);
console.log(`[채번 규칙 ID 저장] ${ruleIdKey} = ${field.numberingRule.ruleId}`);
// 부모 컴포넌트에도 ruleId 전달 (ModalRepeaterTable → ScreenModal)
if (onChange) {
onChange({
onChange({
...updatedData,
[ruleIdKey]: field.numberingRule.ruleId
[ruleIdKey]: field.numberingRule.ruleId,
});
console.log(`[채번] 부모에게 ruleId 전달: ${ruleIdKey}`);
}
@ -452,7 +483,7 @@ export function UniversalFormModalComponent({
}
isGeneratingRef.current = false; // 진행 완료
if (hasChanges) {
setFormData(updatedData);
}

View File

@ -379,10 +379,41 @@ export class ButtonActionExecutor {
/**
* (INSERT/UPDATE - DB )
*/
private static saveCallCount = 0; // 🆕 호출 횟수 추적
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
this.saveCallCount++;
const callId = this.saveCallCount;
const { formData, originalData, tableName, screenId, onSave } = context;
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
const formDataHash = JSON.stringify(Object.keys(formData).sort());
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
const lastCallTime = this.saveLock.get(lockKey) || 0;
const now = Date.now();
const timeDiff = now - lastCallTime;
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
if (timeDiff < 2000) {
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
lockKey: lockKey.slice(0, 50),
timeDiff,
});
return true; // 중복 호출은 성공으로 처리
}
this.saveLock.set(lockKey, now);
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
callId,
formDataKeys: Object.keys(formData),
tableName,
screenId,
hasOnSave: !!onSave,
});
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
if (onSave) {
@ -681,13 +712,52 @@ export class ButtonActionExecutor {
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
const splitPanelData = context.splitPanelParentData || {};
if (Object.keys(splitPanelData).length > 0) {
console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData);
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함
// 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
const rawSplitPanelData = context.splitPanelParentData || {};
// INSERT 모드에서는 연결에 필요한 필드만 추출
const cleanedSplitPanelData: Record<string, any> = {};
// 필수 연결 필드: company_code (멀티테넌시)
if (rawSplitPanelData.company_code) {
cleanedSplitPanelData.company_code = rawSplitPanelData.company_code;
}
// 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
const linkFieldPatterns = ["_code", "_id"];
const excludeFields = [
"id",
"company_code",
"created_date",
"updated_date",
"created_at",
"updated_at",
"writer",
"created_by",
"updated_by",
];
for (const [key, value] of Object.entries(rawSplitPanelData)) {
if (excludeFields.includes(key)) continue;
if (value === undefined || value === null) continue;
// 연결 필드 패턴 확인
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
if (isLinkField) {
cleanedSplitPanelData[key] = value;
console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`);
}
}
if (Object.keys(rawSplitPanelData).length > 0) {
console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData));
console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData);
}
const dataWithUserInfo = {
...splitPanelData, // 분할 패널 부모 데이터 먼저 적용
...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
created_by: writerValue, // created_by는 항상 로그인한 사람
@ -695,6 +765,12 @@ export class ButtonActionExecutor {
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
};
// 🔧 formData에서도 id 제거 (신규 INSERT이므로)
if ("id" in dataWithUserInfo && !formData.id) {
console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id);
delete dataWithUserInfo.id;
}
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataWithUserInfo)) {
if (key.endsWith("_numberingRuleId")) {
@ -762,6 +838,107 @@ export class ButtonActionExecutor {
}
}
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
console.log("🔎 [handleSave] formData 전체:", context.formData);
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
type: typeof fieldValue,
isArray: Array.isArray(fieldValue),
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
});
// JSON 문자열인 경우 파싱
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
}
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
const firstItem = parsedData[0];
const repeaterTargetTable = firstItem?._targetTable;
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey}${repeaterTargetTable}`, {
itemCount: parsedData.length,
});
for (const item of parsedData) {
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
const {
_targetTable: _,
_isNewItem,
_existingRecord: __,
_originalItemIds: ___,
_deletedItemIds: ____,
...dataToSave
} = item;
// 🆕 빈 id 필드 제거 (새 항목인 경우)
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
delete dataToSave.id;
}
// 사용자 정보 추가
const dataWithMeta: Record<string, unknown> = {
...dataToSave,
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
};
try {
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
if (isNewRecord) {
// INSERT (새 항목)
// id 필드 완전히 제거 (자동 생성되도록)
delete dataWithMeta.id;
// 빈 문자열 id도 제거
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
delete dataWithMeta.id;
}
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
const insertResult = await apiClient.post(
`/table-management/tables/${repeaterTargetTable}/add`,
dataWithMeta,
);
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
} else if (item.id) {
// UPDATE (기존 항목)
const originalData = { id: item.id };
const updatedData = { ...dataWithMeta, id: item.id };
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
id: item.id,
table: repeaterTargetTable,
});
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
originalData,
updatedData,
});
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
}
} catch (err) {
const error = err as { response?: { data?: unknown }; message?: string };
console.error(
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
error.response?.data || error.message,
);
}
}
}
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
const repeatScreenModalKeys = Object.keys(context.formData).filter(
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
@ -769,11 +946,36 @@ export class ButtonActionExecutor {
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
const repeaterFieldGroupTables: string[] = [];
for (const [, fieldValue] of Object.entries(context.formData)) {
let parsedData = fieldValue;
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
try {
parsedData = JSON.parse(fieldValue);
} catch {
continue;
}
}
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
}
}
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
const shouldSkipMainSave =
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
if (shouldSkipMainSave) {
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
console.log(
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
{
repeatScreenModalTables,
repeaterFieldGroupTables,
},
);
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
} else {
saveResult = await DynamicFormApi.saveFormData({
screenId,
@ -1578,14 +1780,16 @@ export class ButtonActionExecutor {
/**
*
* 🔧 modal (INSERT)
* edit (UPDATE)
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기:", {
console.log("모달 열기 (신규 등록 모드):", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
selectedRowsData: context.selectedRowsData,
// 🔧 selectedRowsData는 modal 액션에서 사용하지 않음 (신규 등록이므로)
});
if (config.targetScreenId) {
@ -1602,10 +1806,11 @@ export class ButtonActionExecutor {
}
}
// 🆕 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
// 🔧 modal 액션은 신규 등록이므로 selectedData를 전달하지 않음
// selectedData가 있으면 ScreenModal에서 originalData로 인식하여 UPDATE 모드로 동작하게 됨
// edit 액션만 selectedData/editData를 사용하여 UPDATE 모드로 동작
console.log("📦 [handleModal] 신규 등록 모드 - selectedData 전달하지 않음");
console.log("📦 [handleModal] 분할 패널 부모 데이터 (초기값으로 사용):", context.splitPanelParentData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
@ -1614,10 +1819,11 @@ export class ButtonActionExecutor {
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
// 🔧 신규 등록이므로 selectedData/selectedIds를 전달하지 않음
// edit 액션에서만 이 데이터를 사용
selectedData: [],
selectedIds: [],
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 초기값으로 사용)
splitPanelParentData: context.splitPanelParentData || {},
},
});
@ -2624,6 +2830,7 @@ export class ButtonActionExecutor {
/**
* (After Timing)
* EditModal public으로
*
*/
public static async executeAfterSaveControl(
config: ButtonActionConfig,
@ -2635,12 +2842,6 @@ export class ButtonActionExecutor {
dataflowTiming: config.dataflowTiming,
});
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 제어 데이터 소스 결정
let controlDataSource = config.dataflowConfig?.controlDataSource;
if (!controlDataSource) {
@ -2654,9 +2855,117 @@ export class ButtonActionExecutor {
controlDataSource,
};
// 🔥 다중 제어 지원 (flowControls 배열)
const flowControls = config.dataflowConfig?.flowControls || [];
if (flowControls.length > 0) {
console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}`);
// 순서대로 정렬
const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
// 노드 플로우 실행 API
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
const sourceData: any = context.formData || {};
let allSuccess = true;
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
for (let i = 0; i < sortedControls.length; i++) {
const control = sortedControls[i];
// 유효하지 않은 flowId 스킵
if (!control.flowId || control.flowId <= 0) {
console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control);
continue;
}
// executionTiming 체크 (after만 실행)
if (control.executionTiming && control.executionTiming !== "after") {
console.log(
`⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`,
control.executionTiming,
);
continue;
}
console.log(
`\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`,
);
try {
const result = await executeNodeFlow(control.flowId, {
dataSourceType: controlDataSource,
sourceData,
context: extendedContext,
});
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: result.success,
message: result.message,
});
if (result.success) {
console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`);
} else {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`);
allSuccess = false;
// 이전 제어 실패 시 다음 제어 실행 중단
console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단");
break;
}
} catch (error: any) {
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error);
results.push({
flowId: control.flowId,
flowName: control.flowName,
success: false,
message: error.message,
});
allSuccess = false;
break;
}
}
// 결과 요약
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
console.log("\n📊 다중 제어 실행 완료:", {
total: sortedControls.length,
executed: results.length,
success: successCount,
failed: failCount,
});
if (allSuccess) {
toast.success(`${successCount}개 제어 실행 완료`);
} else {
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`);
}
return;
}
// 🔥 기존 단일 제어 실행 (하위 호환성)
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
if (hasFlowConfig) {
// executionTiming 체크
const flowTiming = config.dataflowConfig.flowConfig.executionTiming;
if (flowTiming && flowTiming !== "after") {
console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming);
return;
}
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId } = config.dataflowConfig.flowConfig;
@ -2666,7 +2975,7 @@ export class ButtonActionExecutor {
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비
let sourceData: any = context.formData || {};
const sourceData: any = context.formData || {};
// repeat-screen-modal 데이터가 있으면 병합
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
@ -3967,39 +4276,80 @@ export class ButtonActionExecutor {
try {
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
// 추적 중인지 확인
if (!this.trackingIntervalId) {
toast.warning("진행 중인 위치 추적이 없습니다.");
return false;
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
const isTrackingActive = !!this.trackingIntervalId;
if (!isTrackingActive) {
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
} else {
// 타이머 정리 (추적 중인 경우에만)
clearInterval(this.trackingIntervalId);
this.trackingIntervalId = null;
}
// 타이머 정리
clearInterval(this.trackingIntervalId);
this.trackingIntervalId = null;
const tripId = this.currentTripId;
// 마지막 위치 저장 (trip_status를 completed로)
const departure =
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId =
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
let dbDeparture: string | null = null;
let dbArrival: string | null = null;
let dbVehicleId: string | null = null;
const userId = context.userId || this.trackingUserId;
if (userId) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles";
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
// DB에서 현재 차량 정보 조회
const vehicleResponse = await apiClient.post(
`/table-management/tables/${statusTableName}/data`,
{
page: 1,
size: 1,
search: { [keyField]: userId },
autoFilter: true,
},
);
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
if (vehicleData) {
dbDeparture = vehicleData.departure || null;
dbArrival = vehicleData.arrival || null;
dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null;
console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId });
}
} catch (dbError) {
console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError);
}
}
await this.saveLocationToHistory(
tripId,
departure,
arrival,
departureName,
destinationName,
vehicleId,
"completed",
);
// 마지막 위치 저장 (추적 중이었던 경우에만)
if (isTrackingActive) {
// DB 값 우선, 없으면 formData 사용
const departure = dbDeparture ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = dbArrival ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId = dbVehicleId ||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
// 🆕 거리/시간 계산 및 저장
if (tripId) {
await this.saveLocationToHistory(
tripId,
departure,
arrival,
departureName,
destinationName,
vehicleId,
"completed",
);
}
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
if (isTrackingActive && tripId) {
try {
const tripStats = await this.calculateTripStats(tripId);
console.log("📊 운행 통계:", tripStats);
@ -4111,9 +4461,9 @@ export class ButtonActionExecutor {
}
}
// 상태 변경 (vehicles 테이블 등)
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
const effectiveContext = context.userId ? context : this.trackingContext;
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
const effectiveContext = context.userId ? context : this.trackingContext || context;
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
try {

View File

@ -34,9 +34,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",
@ -3302,16 +3302,16 @@
}
},
"node_modules/@tiptap/core": {
"version": "3.13.0",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.13.0.tgz",
"integrity": "sha512-iUelgiTMgPVMpY5ZqASUpk8mC8HuR9FWKaDzK27w9oWip9tuB54Z8mePTxNcQaSPb6ErzEaC8x8egrRt7OsdGQ==",
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^3.13.0"
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-blockquote": {
@ -3700,19 +3700,6 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/starter-kit/node_modules/@tiptap/core": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@turf/along": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@turf/along/-/along-7.2.0.tgz",
@ -6084,7 +6071,7 @@
"version": "20.19.24",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
@ -6122,7 +6109,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@ -12538,6 +12525,13 @@
"react-dom": ">=16"
}
},
"node_modules/react-is": {
"version": "19.2.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.1.tgz",
"integrity": "sha512-L7BnWgRbMwzMAubQcS7sXdPdNLmKlucPlopgAzx7FtYbksWZgEWiuYM5x9T6UqS2Ne0rsgQTq5kY2SGqpzUkYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
@ -14197,7 +14191,7 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {

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

@ -5,21 +5,21 @@
/**
* (table_type_columns) input_type
*/
export type RepeaterFieldType =
| "text" // 텍스트
| "number" // 숫자
| "textarea" // 텍스트영역
| "date" // 날짜
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio" // 라디오
| "category" // 카테고리
| "entity" // 엔티티 참조
| "code" // 공통코드
| "image" // 이미지
| "direct" // 직접입력
export type RepeaterFieldType =
| "text" // 텍스트
| "number" // 숫자
| "textarea" // 텍스트영역
| "date" // 날짜
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio" // 라디오
| "category" // 카테고리
| "entity" // 엔티티 참조
| "code" // 공통코드
| "image" // 이미지
| "direct" // 직접입력
| "calculated" // 계산식 필드
| string; // 기타 커스텀 타입 허용
| string; // 기타 커스텀 타입 허용
/**
*
@ -32,11 +32,11 @@ export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor
* : { field1: "amount", operator: "round", decimalPlaces: 2 } round(amount, 2)
*/
export interface CalculationFormula {
field1: string; // 첫 번째 필드명
field1: string; // 첫 번째 필드명
operator: CalculationOperator; // 연산자
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
constantValue?: number; // 상수값 (field2 대신 사용 가능)
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
constantValue?: number; // 상수값 (field2 대신 사용 가능)
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
}
/**
@ -84,6 +84,7 @@ export interface RepeaterFieldGroupConfig {
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
fkColumn?: string; // 분할 패널에서 좌측 선택 데이터와 연결할 FK 컬럼 (예: "serial_no")
minItems?: number; // 최소 항목 수
maxItems?: number; // 최대 항목 수
addButtonText?: string; // 추가 버튼 텍스트

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

View File

@ -1684,3 +1684,4 @@ const 출고등록_설정: ScreenSplitPanel = {

View File

@ -531,3 +531,4 @@ const { data: config } = await getScreenSplitPanel(screenId);

View File

@ -518,3 +518,4 @@ function ScreenViewPage() {