Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-02-12 14:19:31 +09:00
commit 5d391f0cee
20 changed files with 555 additions and 481 deletions

View File

@ -18,6 +18,7 @@
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0", "html-to-docx": "^1.8.0",
@ -5948,6 +5949,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@ -5989,6 +5991,15 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-async-errors": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/express-async-errors/-/express-async-errors-3.1.1.tgz",
"integrity": "sha512-h6aK1da4tpqWSbyCa3FxB/V6Ehd4EEB15zyQq9qe75OZBp0krinNKuH4rAY+S/U/2I36vdLAUFSjQJ+TFmODng==",
"license": "ISC",
"peerDependencies": {
"express": "^4.16.2"
}
},
"node_modules/express-rate-limit": { "node_modules/express-rate-limit": {
"version": "7.5.1", "version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",

View File

@ -32,6 +32,7 @@
"docx": "^9.5.1", "docx": "^9.5.1",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-async-errors": "^3.1.1",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"html-to-docx": "^1.8.0", "html-to-docx": "^1.8.0",

View File

@ -1,4 +1,5 @@
import "dotenv/config"; import "dotenv/config";
import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import helmet from "helmet"; import helmet from "helmet";

View File

@ -19,8 +19,6 @@ export async function getAdminMenus(
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
logger.info("=== 관리자 메뉴 목록 조회 시작 ===");
// 현재 로그인한 사용자의 정보 가져오기 // 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId; const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userCompanyCode = req.user?.companyCode || "ILSHIN";
@ -29,13 +27,6 @@ export async function getAdminMenus(
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가 const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
logger.info(`사용자 ID: ${userId}`);
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
logger.info(`사용자 유형: ${userType}`);
logger.info(`사용자 로케일: ${userLang}`);
logger.info(`메뉴 타입: ${menuType || "전체"}`);
logger.info(`비활성 메뉴 포함: ${includeInactive}`);
const paramMap = { const paramMap = {
userId, userId,
userCompanyCode, userCompanyCode,
@ -47,13 +38,6 @@ export async function getAdminMenus(
const menuList = await AdminService.getAdminMenuList(paramMap); const menuList = await AdminService.getAdminMenuList(paramMap);
logger.info(
`관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", menuList[0]);
}
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
success: true, success: true,
message: "관리자 메뉴 목록 조회 성공", message: "관리자 메뉴 목록 조회 성공",
@ -85,19 +69,12 @@ export async function getUserMenus(
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
logger.info("=== 사용자 메뉴 목록 조회 시작 ===");
// 현재 로그인한 사용자의 정보 가져오기 // 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId; const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType; const userType = req.user?.userType;
const userLang = (req.query.userLang as string) || "ko"; const userLang = (req.query.userLang as string) || "ko";
logger.info(`사용자 ID: ${userId}`);
logger.info(`사용자 회사 코드: ${userCompanyCode}`);
logger.info(`사용자 유형: ${userType}`);
logger.info(`사용자 로케일: ${userLang}`);
const paramMap = { const paramMap = {
userId, userId,
userCompanyCode, userCompanyCode,
@ -107,13 +84,6 @@ export async function getUserMenus(
const menuList = await AdminService.getUserMenuList(paramMap); const menuList = await AdminService.getUserMenuList(paramMap);
logger.info(
`사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", menuList[0]);
}
const response: ApiResponse<any[]> = { const response: ApiResponse<any[]> = {
success: true, success: true,
message: "사용자 메뉴 목록 조회 성공", message: "사용자 메뉴 목록 조회 성공",
@ -473,7 +443,7 @@ export const getUserLocale = async (
res: Response res: Response
): Promise<void> => { ): Promise<void> => {
try { try {
logger.info("사용자 로케일 조회 요청", { logger.debug("사용자 로케일 조회 요청", {
query: req.query, query: req.query,
user: req.user, user: req.user,
}); });
@ -496,7 +466,7 @@ export const getUserLocale = async (
if (userInfo?.locale) { if (userInfo?.locale) {
userLocale = userInfo.locale; userLocale = userInfo.locale;
logger.info("데이터베이스에서 사용자 로케일 조회 성공", { logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
userId: req.user.userId, userId: req.user.userId,
locale: userLocale, locale: userLocale,
}); });
@ -513,7 +483,7 @@ export const getUserLocale = async (
message: "사용자 로케일 조회 성공", message: "사용자 로케일 조회 성공",
}; };
logger.info("사용자 로케일 조회 성공", { logger.debug("사용자 로케일 조회 성공", {
userLocale, userLocale,
userId: req.user.userId, userId: req.user.userId,
fromDatabase: !!userInfo?.locale, fromDatabase: !!userInfo?.locale,
@ -618,7 +588,7 @@ export const getCompanyList = async (
res: Response res: Response
) => { ) => {
try { try {
logger.info("회사 목록 조회 요청", { logger.debug("회사 목록 조회 요청", {
query: req.query, query: req.query,
user: req.user, user: req.user,
}); });
@ -658,12 +628,8 @@ export const getCompanyList = async (
message: "회사 목록 조회 성공", message: "회사 목록 조회 성공",
}; };
logger.info("회사 목록 조회 성공", { logger.debug("회사 목록 조회 성공", {
totalCount: companies.length, totalCount: companies.length,
companies: companies.map((c) => ({
code: c.company_code,
name: c.company_name,
})),
}); });
res.status(200).json(response); res.status(200).json(response);
@ -1864,7 +1830,7 @@ export async function getCompanyListFromDB(
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user }); logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
// Raw Query로 회사 목록 조회 // Raw Query로 회사 목록 조회
const companies = await query<any>( const companies = await query<any>(
@ -1884,7 +1850,7 @@ export async function getCompanyListFromDB(
ORDER BY regdate DESC` ORDER BY regdate DESC`
); );
logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
const response: ApiResponse<any> = { const response: ApiResponse<any> = {
success: true, success: true,

View File

@ -17,9 +17,7 @@ export class AuthController {
const { userId, password }: LoginRequest = req.body; const { userId, password }: LoginRequest = req.body;
const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown";
logger.info(`=== API 로그인 호출됨 ===`); logger.debug(`로그인 요청: ${userId}`);
logger.info(`userId: ${userId}`);
logger.info(`password: ${password ? "***" : "null"}`);
// 입력값 검증 // 입력값 검증
if (!userId || !password) { if (!userId || !password) {
@ -50,14 +48,7 @@ export class AuthController {
companyCode: loginResult.userInfo.companyCode || "ILSHIN", companyCode: loginResult.userInfo.companyCode || "ILSHIN",
}; };
logger.info(`=== API 로그인 사용자 정보 디버그 ===`); logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`);
logger.info(
`PersonBean companyCode: ${loginResult.userInfo.companyCode}`
);
logger.info(`반환할 사용자 정보:`);
logger.info(`- userId: ${userInfo.userId}`);
logger.info(`- userName: ${userInfo.userName}`);
logger.info(`- companyCode: ${userInfo.companyCode}`);
// 사용자의 첫 번째 접근 가능한 메뉴 조회 // 사용자의 첫 번째 접근 가능한 메뉴 조회
let firstMenuPath: string | null = null; let firstMenuPath: string | null = null;
@ -71,7 +62,7 @@ export class AuthController {
}; };
const menuList = await AdminService.getUserMenuList(paramMap); const menuList = await AdminService.getUserMenuList(paramMap);
logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`);
// 접근 가능한 첫 번째 메뉴 찾기 // 접근 가능한 첫 번째 메뉴 찾기
// 조건: // 조건:
@ -87,16 +78,9 @@ export class AuthController {
if (firstMenu) { if (firstMenu) {
firstMenuPath = firstMenu.menu_url || firstMenu.url; firstMenuPath = firstMenu.menu_url || firstMenu.url;
logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { logger.debug(`첫 번째 메뉴: ${firstMenuPath}`);
name: firstMenu.menu_name_kor || firstMenu.translated_name,
url: firstMenuPath,
level: firstMenu.lev || firstMenu.level,
seq: firstMenu.seq,
});
} else { } else {
logger.info( logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동");
"⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다."
);
} }
} catch (menuError) { } catch (menuError) {
logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError);

View File

@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) {
? `WHERE ${whereConditions.join(" AND ")}` ? `WHERE ${whereConditions.join(" AND ")}`
: ""; : "";
// 정렬 컬럼 결정: id가 있으면 id, 없으면 첫 번째 컬럼 사용
let orderByColumn = "1"; // 기본: 첫 번째 컬럼
if (existingColumns.has("id")) {
orderByColumn = '"id"';
} else {
// PK 컬럼 조회 시도
try {
const pkResult = await pool.query(
`SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass AND i.indisprimary
ORDER BY array_position(i.indkey, a.attnum)
LIMIT 1`,
[tableName]
);
if (pkResult.rows.length > 0) {
orderByColumn = `"${pkResult.rows[0].attname}"`;
}
} catch {
// PK 조회 실패 시 기본값 유지
}
}
// 쿼리 실행 (pool은 위에서 이미 선언됨) // 쿼리 실행 (pool은 위에서 이미 선언됨)
const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`;
const dataQuery = ` const dataQuery = `
SELECT * FROM ${tableName} ${whereClause} SELECT * FROM ${tableName} ${whereClause}
ORDER BY id DESC ORDER BY ${orderByColumn} DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;

View File

@ -46,17 +46,7 @@ export class FlowController {
const userId = (req as any).user?.userId || "system"; const userId = (req as any).user?.userId || "system";
const userCompanyCode = (req as any).user?.companyCode; const userCompanyCode = (req as any).user?.companyCode;
console.log("🔍 createFlowDefinition called with:", {
name,
description,
tableName,
dbSourceType,
dbConnectionId,
restApiConnectionId,
restApiEndpoint,
restApiJsonPath,
userCompanyCode,
});
if (!name) { if (!name) {
res.status(400).json({ res.status(400).json({
@ -121,13 +111,7 @@ export class FlowController {
const user = (req as any).user; const user = (req as any).user;
const userCompanyCode = user?.companyCode; const userCompanyCode = user?.companyCode;
console.log("🎯 getFlowDefinitions called:", {
userId: user?.userId,
userCompanyCode: userCompanyCode,
userType: user?.userType,
tableName,
isActive,
});
const flows = await this.flowDefinitionService.findAll( const flows = await this.flowDefinitionService.findAll(
tableName as string | undefined, tableName as string | undefined,
@ -135,7 +119,7 @@ export class FlowController {
userCompanyCode userCompanyCode
); );
console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`);
res.json({ res.json({
success: true, success: true,
@ -583,14 +567,11 @@ export class FlowController {
getStepColumnLabels = async (req: Request, res: Response): Promise<void> => { getStepColumnLabels = async (req: Request, res: Response): Promise<void> => {
try { try {
const { flowId, stepId } = req.params; const { flowId, stepId } = req.params;
console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", {
flowId,
stepId,
});
const step = await this.flowStepService.findById(parseInt(stepId)); const step = await this.flowStepService.findById(parseInt(stepId));
if (!step) { if (!step) {
console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId);
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: "Step not found", message: "Step not found",
@ -602,7 +583,7 @@ export class FlowController {
parseInt(flowId) parseInt(flowId)
); );
if (!flowDef) { if (!flowDef) {
console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId);
res.status(404).json({ res.status(404).json({
success: false, success: false,
message: "Flow definition not found", message: "Flow definition not found",
@ -612,14 +593,10 @@ export class FlowController {
// 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블)
const tableName = step.tableName || flowDef.tableName; const tableName = step.tableName || flowDef.tableName;
console.log("📋 [FlowController] 테이블명 결정:", {
stepTableName: step.tableName,
flowTableName: flowDef.tableName,
selectedTableName: tableName,
});
if (!tableName) { if (!tableName) {
console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음");
res.json({ res.json({
success: true, success: true,
data: {}, data: {},
@ -639,14 +616,7 @@ export class FlowController {
[tableName] [tableName]
); );
console.log(`✅ [FlowController] table_type_columns 조회 완료:`, {
tableName,
rowCount: labelRows.length,
labels: labelRows.map((r) => ({
col: r.column_name,
label: r.column_label,
})),
});
// { columnName: label } 형태의 객체로 변환 // { columnName: label } 형태의 객체로 변환
const labels: Record<string, string> = {}; const labels: Record<string, string> = {};
@ -656,7 +626,7 @@ export class FlowController {
} }
}); });
console.log("📦 [FlowController] 반환할 라벨 객체:", labels);
res.json({ res.json({
success: true, success: true,

View File

@ -86,9 +86,9 @@ export const optionalAuth = (
if (token) { if (token) {
const userInfo: PersonBean = JwtUtils.verifyToken(token); const userInfo: PersonBean = JwtUtils.verifyToken(token);
req.user = userInfo; req.user = userInfo;
logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`);
} else { } else {
logger.info(`선택적 인증: 토큰 없음 (${req.ip})`); logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`);
} }
next(); next();

View File

@ -7,7 +7,7 @@ export class AdminService {
*/ */
static async getAdminMenuList(paramMap: any): Promise<any[]> { static async getAdminMenuList(paramMap: any): Promise<any[]> {
try { try {
logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); logger.debug("AdminService.getAdminMenuList 시작");
const { const {
userId, userId,
@ -155,7 +155,7 @@ export class AdminService {
!isManagementScreen !isManagementScreen
) { ) {
// 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시
logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); logger.debug(`최고 관리자: 공통 메뉴 표시`);
// unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만)
unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`;
} }
@ -168,18 +168,18 @@ export class AdminService {
// SUPER_ADMIN // SUPER_ADMIN
if (isManagementScreen) { if (isManagementScreen) {
// 메뉴 관리 화면: 모든 메뉴 // 메뉴 관리 화면: 모든 메뉴
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
companyFilter = ""; companyFilter = "";
} else { } else {
// 좌측 사이드바: 공통 메뉴만 (company_code = '*') // 좌측 사이드바: 공통 메뉴만 (company_code = '*')
logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시");
companyFilter = `AND MENU.COMPANY_CODE = '*'`; companyFilter = `AND MENU.COMPANY_CODE = '*'`;
} }
} else if (isManagementScreen) { } else if (isManagementScreen) {
// 메뉴 관리 화면: 회사별 필터링 // 메뉴 관리 화면: 회사별 필터링
if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { if (userType === "SUPER_ADMIN" && userCompanyCode === "*") {
// 최고 관리자: 모든 메뉴 (공통 + 모든 회사) // 최고 관리자: 모든 메뉴 (공통 + 모든 회사)
logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시");
companyFilter = ""; companyFilter = "";
} else { } else {
// 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외)
@ -387,16 +387,7 @@ export class AdminService {
queryParams queryParams
); );
logger.info( logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}`);
`관리자 메뉴 목록 조회 결과: ${menuList.length}개 (menuType: ${menuType || "전체"}, userType: ${userType}, company: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", {
objid: menuList[0].objid,
name: menuList[0].menu_name_kor,
companyCode: menuList[0].company_code,
});
}
return menuList; return menuList;
} catch (error) { } catch (error) {
@ -410,7 +401,7 @@ export class AdminService {
*/ */
static async getUserMenuList(paramMap: any): Promise<any[]> { static async getUserMenuList(paramMap: any): Promise<any[]> {
try { try {
logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); logger.debug("AdminService.getUserMenuList 시작");
const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap;
@ -422,9 +413,7 @@ export class AdminService {
// [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시
// TODO: 권한 체크 다시 활성화 필요 // TODO: 권한 체크 다시 활성화 필요
logger.info( logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`);
`⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시`
);
authFilter = ""; authFilter = "";
unionFilter = ""; unionFilter = "";
@ -617,16 +606,7 @@ export class AdminService {
queryParams queryParams
); );
logger.info( logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}`);
`사용자 메뉴 목록 조회 결과: ${menuList.length}개 (userType: ${userType}, company: ${userCompanyCode})`
);
if (menuList.length > 0) {
logger.info("첫 번째 메뉴:", {
objid: menuList[0].objid,
name: menuList[0].menu_name_kor,
companyCode: menuList[0].company_code,
});
}
return menuList; return menuList;
} catch (error) { } catch (error) {

View File

@ -29,12 +29,11 @@ export class AuthService {
if (userInfo && userInfo.user_password) { if (userInfo && userInfo.user_password) {
const dbPassword = userInfo.user_password; const dbPassword = userInfo.user_password;
logger.info(`로그인 시도: ${userId}`); logger.debug(`로그인 시도: ${userId}`);
logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`);
// 마스터 패스워드 체크 (기존 Java 로직과 동일) // 마스터 패스워드 체크 (기존 Java 로직과 동일)
if (password === "qlalfqjsgh11") { if (password === "qlalfqjsgh11") {
logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`);
return { return {
loginResult: true, loginResult: true,
}; };
@ -42,7 +41,7 @@ export class AuthService {
// 비밀번호 검증 (기존 EncryptUtil 로직 사용) // 비밀번호 검증 (기존 EncryptUtil 로직 사용)
if (EncryptUtil.matches(password, dbPassword)) { if (EncryptUtil.matches(password, dbPassword)) {
logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`);
return { return {
loginResult: true, loginResult: true,
}; };
@ -98,7 +97,7 @@ export class AuthService {
] ]
); );
logger.info( logger.debug(
`로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})`
); );
} catch (error) { } catch (error) {
@ -225,7 +224,7 @@ export class AuthService {
// deptCode: personBean.deptCode, // deptCode: personBean.deptCode,
//}); //});
logger.info(`사용자 정보 조회 완료: ${userId}`); logger.debug(`사용자 정보 조회 완료: ${userId}`);
return personBean; return personBean;
} catch (error) { } catch (error) {
logger.error( logger.error(

View File

@ -31,13 +31,6 @@ export class FlowExecutionService {
throw new Error(`Flow definition not found: ${flowId}`); throw new Error(`Flow definition not found: ${flowId}`);
} }
console.log("🔍 [getStepDataCount] Flow Definition:", {
flowId,
dbSourceType: flowDef.dbSourceType,
dbConnectionId: flowDef.dbConnectionId,
tableName: flowDef.tableName,
});
// 2. 플로우 단계 조회 // 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId); const step = await this.flowStepService.findById(stepId);
if (!step) { if (!step) {
@ -59,36 +52,21 @@ export class FlowExecutionService {
// 5. 카운트 쿼리 실행 (내부 또는 외부 DB) // 5. 카운트 쿼리 실행 (내부 또는 외부 DB)
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`; const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
console.log("🔍 [getStepDataCount] Query Info:", {
tableName,
query,
params,
isExternal: flowDef.dbSourceType === "external",
connectionId: flowDef.dbConnectionId,
});
let result: any; let result: any;
if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) {
// 외부 DB 조회 // 외부 DB 조회
console.log(
"✅ [getStepDataCount] Using EXTERNAL DB:",
flowDef.dbConnectionId
);
const externalResult = await executeExternalQuery( const externalResult = await executeExternalQuery(
flowDef.dbConnectionId, flowDef.dbConnectionId,
query, query,
params params
); );
console.log("📦 [getStepDataCount] External result:", externalResult);
result = externalResult.rows; result = externalResult.rows;
} else { } else {
// 내부 DB 조회 // 내부 DB 조회
console.log("✅ [getStepDataCount] Using INTERNAL DB");
result = await db.query(query, params); result = await db.query(query, params);
} }
const count = parseInt(result[0].count || result[0].COUNT); const count = parseInt(result[0].count || result[0].COUNT);
console.log("✅ [getStepDataCount] Final count:", count);
return count; return count;
} }

View File

@ -93,13 +93,6 @@ export class FlowStepService {
id: number, id: number,
request: UpdateFlowStepRequest request: UpdateFlowStepRequest
): Promise<FlowStep | null> { ): Promise<FlowStep | null> {
console.log("🔧 FlowStepService.update called with:", {
id,
statusColumn: request.statusColumn,
statusValue: request.statusValue,
fullRequest: JSON.stringify(request),
});
// 조건 검증 // 조건 검증
if (request.conditionJson) { if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson); FlowConditionParser.validateConditionGroup(request.conditionJson);
@ -276,14 +269,6 @@ export class FlowStepService {
// JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌
const displayConfig = row.display_config; const displayConfig = row.display_config;
// 디버깅 로그 (개발 환경에서만)
if (displayConfig && process.env.NODE_ENV === "development") {
console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, {
type: typeof displayConfig,
value: displayConfig,
});
}
return { return {
id: row.id, id: row.id,
flowDefinitionId: row.flow_definition_id, flowDefinitionId: row.flow_definition_id,

View File

@ -60,6 +60,8 @@ export interface ExecutionContext {
buttonContext?: ButtonContext; buttonContext?: ButtonContext;
// 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all) // 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all)
currentNodeDataSourceType?: string; currentNodeDataSourceType?: string;
// 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용)
originalData?: Record<string, any> | null;
} }
export interface ButtonContext { export interface ButtonContext {
@ -248,8 +250,14 @@ export class NodeFlowExecutionService {
contextData.selectedRowsData || contextData.selectedRowsData ||
contextData.context?.selectedRowsData, contextData.context?.selectedRowsData,
}, },
// 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용)
originalData: contextData.originalData || null,
}; };
if (context.originalData) {
logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`);
}
logger.info(`📦 실행 컨텍스트:`, { logger.info(`📦 실행 컨텍스트:`, {
dataSourceType: context.dataSourceType, dataSourceType: context.dataSourceType,
sourceDataCount: context.sourceData?.length || 0, sourceDataCount: context.sourceData?.length || 0,
@ -3020,6 +3028,14 @@ export class NodeFlowExecutionService {
} }
try { try {
// 저장 전 원본 데이터가 있으면 DB 조회 대신 원본 데이터 사용
// (after 타이밍에서는 DB가 이미 업데이트되어 있으므로 원본 데이터가 필요)
if (context.originalData && Object.keys(context.originalData).length > 0) {
logger.info(`🎯 조건 노드: 저장 전 원본 데이터(originalData) 사용 (DB 조회 스킵)`);
logger.info(`🎯 originalData 필드: ${Object.keys(context.originalData).join(", ")}`);
return context.originalData;
}
const whereConditions = targetLookup.lookupKeys const whereConditions = targetLookup.lookupKeys
.map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`)
.join(" AND "); .join(" AND ");

View File

@ -1739,7 +1739,7 @@ export class ScreenManagementService {
// V2 레이아웃이 있으면 V2 형식으로 반환 // V2 레이아웃이 있으면 V2 형식으로 반환
if (v2Layout && v2Layout.layout_data) { if (v2Layout && v2Layout.layout_data) {
console.log(`V2 레이아웃 발견, V2 형식으로 반환`);
const layoutData = v2Layout.layout_data; const layoutData = v2Layout.layout_data;
// URL에서 컴포넌트 타입 추출하는 헬퍼 함수 // URL에서 컴포넌트 타입 추출하는 헬퍼 함수
@ -1799,7 +1799,7 @@ export class ScreenManagementService {
}; };
} }
console.log(`V2 레이아웃 없음, V1 테이블 조회`);
const layouts = await query<any>( const layouts = await query<any>(
`SELECT * FROM screen_layouts `SELECT * FROM screen_layouts
@ -4254,7 +4254,7 @@ export class ScreenManagementService {
[newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)],
); );
console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`);
} catch (error) { } catch (error) {
console.error("V2 레이아웃 복사 중 오류:", error); console.error("V2 레이아웃 복사 중 오류:", error);
// 레이아웃 복사 실패해도 화면 생성은 유지 // 레이아웃 복사 실패해도 화면 생성은 유지
@ -5045,8 +5045,7 @@ export class ScreenManagementService {
companyCode: string, companyCode: string,
userType?: string, userType?: string,
): Promise<any | null> { ): Promise<any | null> {
console.log(`=== V2 레이아웃 로드 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`);
// SUPER_ADMIN 여부 확인 // SUPER_ADMIN 여부 확인
const isSuperAdmin = userType === "SUPER_ADMIN"; const isSuperAdmin = userType === "SUPER_ADMIN";
@ -5136,13 +5135,11 @@ export class ScreenManagementService {
} }
if (!layout) { if (!layout) {
console.log(`V2 레이아웃 없음: screen_id=${screenId}`);
return null; return null;
} }
console.log(
`V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`,
);
return layout.layout_data; return layout.layout_data;
} }
@ -5162,10 +5159,7 @@ export class ScreenManagementService {
const hasConditionConfig = 'conditionConfig' in layoutData; const hasConditionConfig = 'conditionConfig' in layoutData;
const conditionConfig = layoutData.conditionConfig || null; const conditionConfig = layoutData.conditionConfig || null;
console.log(`=== V2 레이아웃 저장 시작 ===`);
console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 레이어: ${layerId} (${layerName})`);
console.log(`컴포넌트 수: ${layoutData.components?.length || 0}`);
console.log(`조건 설정 포함 여부: ${hasConditionConfig}`);
// 권한 확인 // 권한 확인
const screens = await query<{ company_code: string | null }>( const screens = await query<{ company_code: string | null }>(
@ -5210,7 +5204,7 @@ export class ScreenManagementService {
); );
} }
console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`);
} }
/** /**

View File

@ -874,9 +874,9 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
}, [parentId, config.fieldName, data, handleDataChange]); }, [parentId, config.fieldName, data, handleDataChange]);
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("flex h-full flex-col overflow-hidden", className)}>
{/* 헤더 영역 */} {/* 헤더 영역 */}
<div className="flex items-center justify-between"> <div className="flex shrink-0 items-center justify-between pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
{data.length > 0 && `${data.length}개 항목`} {data.length > 0 && `${data.length}개 항목`}
@ -896,23 +896,25 @@ export const V2Repeater: React.FC<V2RepeaterProps> = ({
</div> </div>
</div> </div>
{/* Repeater 테이블 */} {/* Repeater 테이블 - 남은 공간에서 스크롤 */}
<RepeaterTable <div className="min-h-0 flex-1">
columns={repeaterColumns} <RepeaterTable
data={data} columns={repeaterColumns}
onDataChange={handleDataChange} data={data}
onRowChange={handleRowChange} onDataChange={handleDataChange}
onRowDelete={handleRowDelete} onRowChange={handleRowChange}
activeDataSources={activeDataSources} onRowDelete={handleRowDelete}
onDataSourceChange={(field, optionId) => { activeDataSources={activeDataSources}
setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); onDataSourceChange={(field, optionId) => {
}} setActiveDataSources((prev) => ({ ...prev, [field]: optionId }));
selectedRows={selectedRows} }}
onSelectionChange={setSelectedRows} selectedRows={selectedRows}
equalizeWidthsTrigger={autoWidthTrigger} onSelectionChange={setSelectedRows}
categoryColumns={sourceCategoryColumns} equalizeWidthsTrigger={autoWidthTrigger}
categoryLabelMap={categoryLabelMap} categoryColumns={sourceCategoryColumns}
/> categoryLabelMap={categoryLabelMap}
/>
</div>
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */}
{isModalMode && ( {isModalMode && (

View File

@ -643,8 +643,8 @@ export function RepeaterTable({
return ( return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<div ref={containerRef} className="border border-gray-200 bg-white"> <div ref={containerRef} className="flex h-full flex-col border border-gray-200 bg-white">
<div className="max-h-[400px] overflow-x-auto overflow-y-auto"> <div className="min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<table <table
className="border-collapse text-xs" className="border-collapse text-xs"
style={{ style={{

View File

@ -3508,7 +3508,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
</div> </div>
</div> </div>
)} )}
<CardContent className="flex-1 overflow-auto p-4"> <CardContent className="flex-1 overflow-hidden p-4">
{/* 추가 탭 컨텐츠 */} {/* 추가 탭 컨텐츠 */}
{activeTabIndex > 0 ? ( {activeTabIndex > 0 ? (
(() => { (() => {
@ -3535,103 +3535,226 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
// 탭 컬럼 설정 // 탭 컬럼 설정
const tabColumns = currentTabConfig?.columns || []; const tabColumns = currentTabConfig?.columns || [];
// 테이블 모드로 표시 // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기)
if (currentTabConfig?.displayMode === "table") { if (currentTabConfig?.displayMode === "table") {
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
return ( return (
<div className="overflow-x-auto"> <div className="h-full overflow-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead className="sticky top-0 z-10 bg-background">
<tr className="border-b"> <tr className="border-b-2 border-border/60">
{tabColumns.map((col: any) => ( {tabSummaryColumns.map((col: any) => (
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium"> <th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
{col.label || col.name} {col.label || col.name}
</th> </th>
))} ))}
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( {hasTabActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium"></th> <th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th>
)} )}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{currentTabData.map((item: any, idx: number) => ( {currentTabData.map((item: any, idx: number) => {
<tr key={item.id || idx} className="hover:bg-muted/50 border-b"> const tabItemId = item.id || item.ID || idx;
{tabColumns.map((col: any) => ( const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
<td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue( // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만)
col.name, const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
getEntityJoinValue(item, col.name), const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0
rightCategoryMappings, ? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
col.format, : Object.entries(item)
.filter(([, v]) => v !== null && v !== undefined && v !== "")
.map(([k, v]) => [k, v, ""] as [string, any, string]);
return (
<React.Fragment key={tabItemId}>
<tr
className={cn(
"cursor-pointer border-b border-border/40 transition-colors",
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
)} )}
</td> onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
))} >
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( {tabSummaryColumns.map((col: any) => (
<td className="px-3 py-2 text-right"> <td key={col.name} className="px-3 py-2 text-xs">
<div className="flex items-center justify-end gap-1"> {formatCellValue(
{currentTabConfig?.showEdit && ( col.name,
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs" getEntityJoinValue(item, col.name),
onClick={() => handleEditClick("right", item)} rightCategoryMappings,
> col.format,
<Pencil className="h-3 w-3" /> )}
</Button> </td>
)} ))}
{currentTabConfig?.showDelete && ( {hasTabActions && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs" <td className="px-3 py-2 text-right">
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)} <div className="flex items-center justify-end gap-1">
> {currentTabConfig?.showEdit && (
<Trash2 className="h-3 w-3" /> <Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
</Button> onClick={(e) => {
)} e.stopPropagation();
</div> handleEditClick("right", item);
</td> }}
)} >
</tr> <Pencil className="h-3 w-3" />
))} </Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={(e) => {
e.stopPropagation();
handleDeleteClick("right", item, currentTabConfig?.tableName);
}}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
)}
</tr>
{/* 상세 정보 (행 클릭 시 펼쳐짐) */}
{isTabExpanded && (
<tr>
<td colSpan={tabSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
<div className="mb-1 text-xs font-semibold"> </div>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-border divide-y">
{tabAllValues.map(([key, value, label]) => {
const displayValue = (value === null || value === undefined || value === "")
? "-"
: formatCellValue(key, value, rightCategoryMappings);
return (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
{label || getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody> </tbody>
</table> </table>
</div> </div>
); );
} }
// 리스트(카드) 모드로 표시 // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시)
return ( {
<div className="space-y-2"> const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
{currentTabData.map((item: any, idx: number) => ( // showInSummary가 false가 아닌 것만 메인 테이블에 표시
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3"> const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
<div className="flex flex-wrap items-center gap-2 text-xs"> return (
{tabColumns.map((col: any) => ( <div className="h-full overflow-auto">
<span key={col.name}> <table className="w-full text-sm">
{formatCellValue( <thead className="sticky top-0 z-10 bg-background">
col.name, <tr className="border-b-2 border-border/60">
getEntityJoinValue(item, col.name), {listSummaryColumns.map((col: any) => (
rightCategoryMappings, <th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
col.format, {col.label || col.name}
)} </th>
</span> ))}
))} {hasTabActions && (
</div> <th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th>
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
<div className="flex items-center gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={() => handleEditClick("right", item)}
>
<Pencil className="h-3 w-3" />
</Button>
)} )}
{currentTabConfig?.showDelete && ( </tr>
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs" </thead>
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)} <tbody>
> {currentTabData.map((item: any, idx: number) => {
<Trash2 className="h-3 w-3" /> const tabItemId = item.id || item.ID || idx;
</Button> const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
)} // showInDetail이 false가 아닌 것만 상세에 표시
</div> const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
)} const tabAllValues: [string, any, string][] = listDetailColumns.length > 0
</div> ? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
))} : Object.entries(item)
</div> .filter(([, v]) => v !== null && v !== undefined && v !== "")
); .map(([k, v]) => [k, v, ""] as [string, any, string]);
return (
<React.Fragment key={tabItemId}>
<tr
className={cn(
"cursor-pointer border-b border-border/40 transition-colors",
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
)}
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
>
{listSummaryColumns.map((col: any) => (
<td key={col.name} className="px-3 py-2 text-xs">
{formatCellValue(
col.name,
getEntityJoinValue(item, col.name),
rightCategoryMappings,
col.format,
)}
</td>
))}
{hasTabActions && (
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-1">
{currentTabConfig?.showEdit && (
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleEditClick("right", item); }}
>
<Pencil className="h-3 w-3" />
</Button>
)}
{currentTabConfig?.showDelete && (
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
onClick={(e) => { e.stopPropagation(); handleDeleteClick("right", item, currentTabConfig?.tableName); }}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
</td>
)}
</tr>
{isTabExpanded && (
<tr>
<td colSpan={listSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
<div className="mb-1 text-xs font-semibold"> </div>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-border divide-y">
{tabAllValues.map(([key, value, label]) => {
const displayValue = (value === null || value === undefined || value === "")
? "-" : formatCellValue(key, value, rightCategoryMappings);
return (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
{label || getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
);
}
})() })()
) : componentConfig.rightPanel?.displayMode === "custom" ? ( ) : componentConfig.rightPanel?.displayMode === "custom" ? (
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
@ -3891,12 +4014,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
let columnsToShow: any[] = []; let columnsToShow: any[] = [];
if (displayColumns.length > 0) { if (displayColumns.length > 0) {
// 설정된 컬럼 사용 // 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시)
columnsToShow = displayColumns.map((col) => ({ columnsToShow = displayColumns
...col, .filter((col) => col.showInSummary !== false)
label: rightColumnLabels[col.name] || col.label || col.name, .map((col) => ({
format: col.format, ...col,
})); label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format,
}));
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
if (isGroupedMode && keyColumns.length > 0) { if (isGroupedMode && keyColumns.length > 0) {
@ -3931,21 +4056,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
} }
return ( return (
<div className="w-full"> <div className="flex h-full w-full flex-col">
<div className="text-muted-foreground mb-2 text-xs"> <div className="min-h-0 flex-1 overflow-auto">
{filteredData.length} <table className="min-w-full">
{rightSearchQuery && filteredData.length !== rightData.length && ( <thead className="sticky top-0 z-10">
<span className="text-primary ml-1">( {rightData.length} )</span> <tr className="border-b-2 border-border/60">
)}
</div>
<div className="overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="sticky top-0 z-10 bg-gray-50">
<tr>
{columnsToShow.map((col, idx) => ( {columnsToShow.map((col, idx) => (
<th <th
key={idx} key={idx}
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap" className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
style={{ style={{
width: col.width ? `${col.width}px` : "auto", width: col.width ? `${col.width}px` : "auto",
minWidth: "80px", minWidth: "80px",
@ -3959,22 +4078,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{!isDesignMode && {!isDesignMode &&
((componentConfig.rightPanel?.editButton?.enabled ?? true) || ((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase"> <th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">
</th> </th>
)} )}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200 bg-white"> <tbody>
{filteredData.map((item, idx) => { {filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx; const itemId = item.id || item.ID || idx;
return ( return (
<tr key={itemId} className="hover:bg-accent transition-colors"> <tr key={itemId} className={cn("border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
{columnsToShow.map((col, colIdx) => ( {columnsToShow.map((col, colIdx) => (
<td <td
key={colIdx} key={colIdx}
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900" className="px-3 py-2 text-xs whitespace-nowrap"
style={{ textAlign: col.align || "left" }} style={{ textAlign: col.align || "left" }}
> >
{formatCellValue( {formatCellValue(
@ -4032,176 +4151,155 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
); );
} }
// 목록 모드 (기존) // 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시)
return filteredData.length > 0 ? ( {
<div className="space-y-2"> // 표시 컬럼 결정
<div className="text-muted-foreground mb-2 text-xs"> const rightColumns = componentConfig.rightPanel?.columns;
{filteredData.length} let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = [];
{rightSearchQuery && filteredData.length !== rightData.length && (
<span className="text-primary ml-1">( {rightData.length} )</span>
)}
</div>
{filteredData.map((item, index) => {
const itemId = item.id || item.ID || index;
const isExpanded = expandedRightItems.has(itemId);
// 우측 패널 표시 컬럼 설정 확인 if (rightColumns && rightColumns.length > 0) {
const rightColumns = componentConfig.rightPanel?.columns; // showInSummary가 false가 아닌 것만 메인 테이블에 표시
let firstValues: [string, any, string][] = []; columnsToDisplay = rightColumns
let allValues: [string, any, string][] = []; .filter((col) => col.showInSummary !== false)
.map((col) => ({
name: col.name,
label: rightColumnLabels[col.name] || col.label || col.name,
format: col.format,
bold: col.bold,
}));
} else if (filteredData.length > 0) {
columnsToDisplay = Object.keys(filteredData[0])
.filter((key) => shouldShowField(key))
.slice(0, 6)
.map((key) => ({
name: key,
label: rightColumnLabels[key] || key,
}));
}
if (rightColumns && rightColumns.length > 0) { const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
// 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로) const hasActions = hasEditButton || hasDeleteButton;
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
firstValues = rightColumns
.slice(0, summaryCount)
.map((col) => {
const value = getEntityJoinValue(item, col.name);
return [col.name, value, col.label] as [string, any, string];
});
allValues = rightColumns return filteredData.length > 0 ? (
.map((col) => { <div className="flex h-full w-full flex-col">
const value = getEntityJoinValue(item, col.name); <div className="min-h-0 flex-1 overflow-auto">
return [col.name, value, col.label] as [string, any, string]; <table className="w-full text-sm">
}); <thead className="sticky top-0 z-10 bg-background">
} else { <tr className="border-b-2 border-border/60">
// 설정 없으면 모든 컬럼 표시 (기존 로직) {columnsToDisplay.map((col) => (
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; <th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
firstValues = Object.entries(item) {col.label}
.filter(([key]) => !key.toLowerCase().includes("id")) </th>
.slice(0, summaryCount) ))}
.map(([key, value]) => [key, value, ""] as [string, any, string]); {hasActions && (
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold"></th>
)}
</tr>
</thead>
<tbody>
{filteredData.map((item, idx) => {
const itemId = item.id || item.ID || idx;
const isExpanded = expandedRightItems.has(itemId);
allValues = Object.entries(item) // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시)
.filter(([key, value]) => value !== null && value !== undefined && value !== "") let allValues: [string, any, string][] = [];
.map(([key, value]) => [key, value, ""] as [string, any, string]); if (rightColumns && rightColumns.length > 0) {
} allValues = rightColumns
.filter((col) => col.showInDetail !== false)
.map((col) => {
const value = getEntityJoinValue(item, col.name);
return [col.name, value, col.label] as [string, any, string];
});
} else {
allValues = Object.entries(item)
.filter(([, value]) => value !== null && value !== undefined && value !== "")
.map(([key, value]) => [key, value, ""] as [string, any, string]);
}
return ( return (
<div <React.Fragment key={itemId}>
key={itemId} <tr
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md" className={cn(
> "cursor-pointer border-b border-border/40 transition-colors",
{/* 요약 정보 */} isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
<div className="p-3"> )}
<div className="flex items-start justify-between gap-2"> onClick={() => toggleRightItemExpansion(itemId)}
<div >
className="min-w-0 flex-1 cursor-pointer" {columnsToDisplay.map((col) => (
onClick={() => toggleRightItemExpansion(itemId)} <td key={col.name} className="px-3 py-2 text-xs">
> {formatCellValue(
<div className="flex flex-wrap items-center gap-x-4 gap-y-2"> col.name,
{firstValues.map(([key, value, label], idx) => { getEntityJoinValue(item, col.name),
// 포맷 설정 및 볼드 설정 찾기 rightCategoryMappings,
const colConfig = rightColumns?.find((c) => c.name === key); col.format,
const format = colConfig?.format;
const boldValue = colConfig?.bold ?? false;
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
const displayValue = (value === null || value === undefined || value === "")
? "-"
: formatCellValue(key, value, rightCategoryMappings, format);
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
return (
<div key={key} className="flex items-baseline gap-1">
{showLabel && (
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
{label || getColumnLabel(key)}:
</span>
)} )}
<span </td>
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`} ))}
> {hasActions && (
{displayValue} <td className="px-3 py-2 text-right">
</span> <div className="flex items-center justify-end gap-1">
</div> {hasEditButton && (
); <Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
})} onClick={(e) => {
</div> e.stopPropagation();
</div> handleEditClick("right", item);
<div className="flex flex-shrink-0 items-start gap-1 pt-1"> }}
{/* 수정 버튼 */} >
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( <Pencil className="h-3 w-3" />
<Button </Button>
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"} )}
size="sm" {hasDeleteButton && (
onClick={(e) => { <Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
e.stopPropagation(); onClick={(e) => {
handleEditClick("right", item); e.stopPropagation();
}} handleDeleteClick("right", item);
className="h-7" }}
> >
<Pencil className="mr-1 h-3 w-3" /> <Trash2 className="h-3 w-3" />
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"} </Button>
</Button> )}
)} </div>
{/* 삭제 버튼 */} </td>
{!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( )}
<button </tr>
onClick={(e) => { {/* 상세 정보 (행 클릭 시 펼쳐짐) */}
e.stopPropagation(); {isExpanded && (
handleDeleteClick("right", item); <tr>
}} <td colSpan={columnsToDisplay.length + (hasActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
className="rounded p-1 transition-colors hover:bg-red-100" <div className="mb-1 text-xs font-semibold"> </div>
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"} <div className="bg-card overflow-auto rounded-md border">
> <table className="w-full text-sm">
<Trash2 className="h-4 w-4 text-red-600" /> <tbody className="divide-border divide-y">
</button> {allValues.map(([key, value, label]) => {
)} const colConfig = rightColumns?.find((c) => c.name === key);
{/* 확장/접기 버튼 */} const format = colConfig?.format;
<button const displayValue = (value === null || value === undefined || value === "")
onClick={() => toggleRightItemExpansion(itemId)} ? "-"
className="rounded p-1 transition-colors hover:bg-gray-200" : formatCellValue(key, value, rightCategoryMappings, format);
> return (
{isExpanded ? ( <tr key={key} className="hover:bg-muted">
<ChevronUp className="text-muted-foreground h-5 w-5" /> <td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
) : ( {label || getColumnLabel(key)}
<ChevronDown className="text-muted-foreground h-5 w-5" /> </td>
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</td>
</tr>
)} )}
</button> </React.Fragment>
</div> );
</div> })}
</div> </tbody>
</table>
{/* 상세 정보 (확장 시 표시) */} </div>
{isExpanded && ( </div>
<div className="bg-muted/50 border-t px-3 py-2"> ) : (
<div className="mb-2 text-xs font-semibold"> </div>
<div className="bg-card overflow-auto rounded-md border">
<table className="w-full text-sm">
<tbody className="divide-border divide-y">
{allValues.map(([key, value, label]) => {
// 포맷 설정 찾기
const colConfig = rightColumns?.find((c) => c.name === key);
const format = colConfig?.format;
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
const displayValue = (value === null || value === undefined || value === "")
? "-"
: formatCellValue(key, value, rightCategoryMappings, format);
return (
<tr key={key} className="hover:bg-muted">
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
{label || getColumnLabel(key)}
</td>
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-muted-foreground py-8 text-center text-sm"> <div className="text-muted-foreground py-8 text-center text-sm">
{rightSearchQuery ? ( {rightSearchQuery ? (
<> <>
@ -4213,6 +4311,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
)} )}
</div> </div>
); );
}
})() })()
) : ( ) : (
// 상세 모드: 단일 객체를 상세 정보로 표시 // 상세 모드: 단일 객체를 상세 정보로 표시
@ -4229,8 +4328,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
rightColumns.map((c) => `${c.name} (${c.label})`), rightColumns.map((c) => `${c.name} (${c.label})`),
); );
// 설정된 컬럼만 표시 // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만)
displayEntries = rightColumns displayEntries = rightColumns
.filter((col) => col.showInDetail !== false)
.map((col) => { .map((col) => {
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
let value = rightData[col.name]; let value = rightData[col.name];

View File

@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
// 드래그 가능한 컬럼 아이템 // 드래그 가능한 컬럼 아이템
function SortableColumnRow({ function SortableColumnRow({
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
}: { }: {
id: string; id: string;
col: { name: string; label: string; width?: number; format?: any }; col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
index: number; index: number;
isNumeric: boolean; isNumeric: boolean;
isEntityJoin?: boolean; isEntityJoin?: boolean;
@ -39,6 +39,8 @@ function SortableColumnRow({
onWidthChange: (value: number) => void; onWidthChange: (value: number) => void;
onFormatChange: (checked: boolean) => void; onFormatChange: (checked: boolean) => void;
onRemove: () => void; onRemove: () => void;
onShowInSummaryChange?: (checked: boolean) => void;
onShowInDetailChange?: (checked: boolean) => void;
}) { }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
const style = { transform: CSS.Transform.toString(transform), transition }; const style = { transform: CSS.Transform.toString(transform), transition };
@ -84,6 +86,29 @@ function SortableColumnRow({
, ,
</label> </label>
)} )}
{/* 헤더/상세 표시 토글 */}
{onShowInSummaryChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
<input
type="checkbox"
checked={col.showInSummary !== false}
onChange={(e) => onShowInSummaryChange(e.target.checked)}
className="h-3 w-3"
/>
</label>
)}
{onShowInDetailChange && (
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
<input
type="checkbox"
checked={col.showInDetail !== false}
onChange={(e) => onShowInDetailChange(e.target.checked)}
className="h-3 w-3"
/>
</label>
)}
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0"> <Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
@ -621,6 +646,16 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
updateTab({ columns: newColumns }); updateTab({ columns: newColumns });
}} }}
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })} onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInSummary: checked };
updateTab({ columns: newColumns });
}}
onShowInDetailChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateTab({ columns: newColumns });
}}
/> />
); );
})} })}
@ -2332,6 +2367,16 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
updateRightPanel({ columns: newColumns }); updateRightPanel({ columns: newColumns });
}} }}
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })} onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
onShowInSummaryChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInSummary: checked };
updateRightPanel({ columns: newColumns });
}}
onShowInDetailChange={(checked) => {
const newColumns = [...selectedColumns];
newColumns[index] = { ...newColumns[index], showInDetail: checked };
updateRightPanel({ columns: newColumns });
}}
/> />
); );
})} })}

View File

@ -42,6 +42,8 @@ export interface AdditionalTabConfig {
sortable?: boolean; sortable?: boolean;
align?: "left" | "center" | "right"; align?: "left" | "center" | "right";
bold?: boolean; bold?: boolean;
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
format?: { format?: {
type?: "number" | "currency" | "date" | "text"; type?: "number" | "currency" | "date" | "text";
thousandSeparator?: boolean; thousandSeparator?: boolean;
@ -225,6 +227,8 @@ export interface SplitPanelLayoutConfig {
sortable?: boolean; // 정렬 가능 여부 (테이블 모드) sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
align?: "left" | "center" | "right"; // 정렬 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드) bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
format?: { format?: {
type?: "number" | "currency" | "date" | "text"; // 포맷 타입 type?: "number" | "currency" | "date" | "text"; // 포맷 타입
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")

View File

@ -1004,13 +1004,25 @@ export class ButtonActionExecutor {
} }
const primaryKeys = primaryKeyResult.data || []; const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
// 🆕 폴백: formData에 PK가 없으면 originalData에서 PK 추출
// 수정 모달에서 id 입력 필드가 없는 경우 formData에 id가 포함되지 않음
if (!primaryKeyValue && hasRealOriginalData) {
primaryKeyValue = this.extractPrimaryKeyValueFromDB(originalData, primaryKeys);
if (primaryKeyValue) {
// formData에도 PK 값을 주입하여 UPDATE 쿼리에서 사용 가능하게 함
const pkColumn = primaryKeys[0];
formData[pkColumn] = primaryKeyValue;
console.log(`🔑 [handleSave] originalData에서 PK 복원: ${pkColumn} = ${primaryKeyValue}`);
}
}
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단 // 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리 // 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== ""; const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
@ -4170,6 +4182,8 @@ export class ButtonActionExecutor {
dataSourceType: controlDataSource, dataSourceType: controlDataSource,
sourceData, sourceData,
context: extendedContext, context: extendedContext,
// 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용)
originalData: context.originalData || null,
}); });
results.push({ results.push({