diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json index 43b698d2..c365a102 100644 --- a/backend-node/package-lock.json +++ b/backend-node/package-lock.json @@ -18,6 +18,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", @@ -5948,6 +5949,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -5989,6 +5991,15 @@ "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": { "version": "7.5.1", "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", diff --git a/backend-node/package.json b/backend-node/package.json index b1bfa319..310ab401 100644 --- a/backend-node/package.json +++ b/backend-node/package.json @@ -32,6 +32,7 @@ "docx": "^9.5.1", "dotenv": "^16.3.1", "express": "^4.18.2", + "express-async-errors": "^3.1.1", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "html-to-docx": "^1.8.0", diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 1fbefea5..30e684d5 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -1,4 +1,5 @@ import "dotenv/config"; +import "express-async-errors"; // async 라우트 핸들러의 에러를 Express 에러 핸들러로 자동 전달 import express from "express"; import cors from "cors"; import helmet from "helmet"; diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bcfff1d2..7b3b1033 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -19,8 +19,6 @@ export async function getAdminMenus( res: Response ): Promise { try { - logger.info("=== 관리자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; @@ -29,13 +27,6 @@ export async function getAdminMenus( const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가 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 = { userId, userCompanyCode, @@ -47,13 +38,6 @@ export async function getAdminMenus( const menuList = await AdminService.getAdminMenuList(paramMap); - logger.info( - `관리자 메뉴 조회 결과: ${menuList.length}개 (타입: ${menuType || "전체"}, 회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "관리자 메뉴 목록 조회 성공", @@ -85,19 +69,12 @@ export async function getUserMenus( res: Response ): Promise { try { - logger.info("=== 사용자 메뉴 목록 조회 시작 ==="); - // 현재 로그인한 사용자의 정보 가져오기 const userId = req.user?.userId; const userCompanyCode = req.user?.companyCode || "ILSHIN"; const userType = req.user?.userType; const userLang = (req.query.userLang as string) || "ko"; - logger.info(`사용자 ID: ${userId}`); - logger.info(`사용자 회사 코드: ${userCompanyCode}`); - logger.info(`사용자 유형: ${userType}`); - logger.info(`사용자 로케일: ${userLang}`); - const paramMap = { userId, userCompanyCode, @@ -107,13 +84,6 @@ export async function getUserMenus( const menuList = await AdminService.getUserMenuList(paramMap); - logger.info( - `사용자 메뉴 조회 결과: ${menuList.length}개 (회사: ${userCompanyCode})` - ); - if (menuList.length > 0) { - logger.info("첫 번째 메뉴:", menuList[0]); - } - const response: ApiResponse = { success: true, message: "사용자 메뉴 목록 조회 성공", @@ -473,7 +443,7 @@ export const getUserLocale = async ( res: Response ): Promise => { try { - logger.info("사용자 로케일 조회 요청", { + logger.debug("사용자 로케일 조회 요청", { query: req.query, user: req.user, }); @@ -496,7 +466,7 @@ export const getUserLocale = async ( if (userInfo?.locale) { userLocale = userInfo.locale; - logger.info("데이터베이스에서 사용자 로케일 조회 성공", { + logger.debug("데이터베이스에서 사용자 로케일 조회 성공", { userId: req.user.userId, locale: userLocale, }); @@ -513,7 +483,7 @@ export const getUserLocale = async ( message: "사용자 로케일 조회 성공", }; - logger.info("사용자 로케일 조회 성공", { + logger.debug("사용자 로케일 조회 성공", { userLocale, userId: req.user.userId, fromDatabase: !!userInfo?.locale, @@ -618,7 +588,7 @@ export const getCompanyList = async ( res: Response ) => { try { - logger.info("회사 목록 조회 요청", { + logger.debug("회사 목록 조회 요청", { query: req.query, user: req.user, }); @@ -658,12 +628,8 @@ export const getCompanyList = async ( message: "회사 목록 조회 성공", }; - logger.info("회사 목록 조회 성공", { + logger.debug("회사 목록 조회 성공", { totalCount: companies.length, - companies: companies.map((c) => ({ - code: c.company_code, - name: c.company_name, - })), }); res.status(200).json(response); @@ -1864,7 +1830,7 @@ export async function getCompanyListFromDB( res: Response ): Promise { try { - logger.info("회사 목록 조회 요청 (Raw Query)", { user: req.user }); + logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user }); // Raw Query로 회사 목록 조회 const companies = await query( @@ -1884,7 +1850,7 @@ export async function getCompanyListFromDB( ORDER BY regdate DESC` ); - logger.info("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); + logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length }); const response: ApiResponse = { success: true, diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index 1903d397..ebf3e8f5 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -17,9 +17,7 @@ export class AuthController { const { userId, password }: LoginRequest = req.body; const remoteAddr = req.ip || req.connection.remoteAddress || "unknown"; - logger.info(`=== API 로그인 호출됨 ===`); - logger.info(`userId: ${userId}`); - logger.info(`password: ${password ? "***" : "null"}`); + logger.debug(`로그인 요청: ${userId}`); // 입력값 검증 if (!userId || !password) { @@ -50,14 +48,7 @@ export class AuthController { companyCode: loginResult.userInfo.companyCode || "ILSHIN", }; - logger.info(`=== API 로그인 사용자 정보 디버그 ===`); - logger.info( - `PersonBean companyCode: ${loginResult.userInfo.companyCode}` - ); - logger.info(`반환할 사용자 정보:`); - logger.info(`- userId: ${userInfo.userId}`); - logger.info(`- userName: ${userInfo.userName}`); - logger.info(`- companyCode: ${userInfo.companyCode}`); + logger.debug(`로그인 사용자 정보: ${userInfo.userId} (${userInfo.companyCode})`); // 사용자의 첫 번째 접근 가능한 메뉴 조회 let firstMenuPath: string | null = null; @@ -71,7 +62,7 @@ export class AuthController { }; const menuList = await AdminService.getUserMenuList(paramMap); - logger.info(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); + logger.debug(`로그인 후 메뉴 조회: 총 ${menuList.length}개 메뉴`); // 접근 가능한 첫 번째 메뉴 찾기 // 조건: @@ -87,16 +78,9 @@ export class AuthController { if (firstMenu) { firstMenuPath = firstMenu.menu_url || firstMenu.url; - logger.info(`✅ 첫 번째 접근 가능한 메뉴 발견:`, { - name: firstMenu.menu_name_kor || firstMenu.translated_name, - url: firstMenuPath, - level: firstMenu.lev || firstMenu.level, - seq: firstMenu.seq, - }); + logger.debug(`첫 번째 메뉴: ${firstMenuPath}`); } else { - logger.info( - "⚠️ 접근 가능한 메뉴가 없습니다. 메인 페이지로 이동합니다." - ); + logger.debug("접근 가능한 메뉴 없음, 메인 페이지로 이동"); } } catch (menuError) { logger.warn("메뉴 조회 중 오류 발생 (무시하고 계속):", menuError); diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index 2e850a03..bbc42568 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -395,11 +395,35 @@ export async function searchEntity(req: AuthenticatedRequest, res: Response) { ? `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은 위에서 이미 선언됨) const countQuery = `SELECT COUNT(*) FROM ${tableName} ${whereClause}`; const dataQuery = ` SELECT * FROM ${tableName} ${whereClause} - ORDER BY id DESC + ORDER BY ${orderByColumn} DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index b617b262..4a6a1e03 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -46,17 +46,7 @@ export class FlowController { const userId = (req as any).user?.userId || "system"; const userCompanyCode = (req as any).user?.companyCode; - console.log("🔍 createFlowDefinition called with:", { - name, - description, - tableName, - dbSourceType, - dbConnectionId, - restApiConnectionId, - restApiEndpoint, - restApiJsonPath, - userCompanyCode, - }); + if (!name) { res.status(400).json({ @@ -121,13 +111,7 @@ export class FlowController { const user = (req as any).user; const userCompanyCode = user?.companyCode; - console.log("🎯 getFlowDefinitions called:", { - userId: user?.userId, - userCompanyCode: userCompanyCode, - userType: user?.userType, - tableName, - isActive, - }); + const flows = await this.flowDefinitionService.findAll( tableName as string | undefined, @@ -135,7 +119,7 @@ export class FlowController { userCompanyCode ); - console.log(`✅ Returning ${flows.length} flows to user ${user?.userId}`); + res.json({ success: true, @@ -583,14 +567,11 @@ export class FlowController { getStepColumnLabels = async (req: Request, res: Response): Promise => { try { const { flowId, stepId } = req.params; - console.log("🏷️ [FlowController] 컬럼 라벨 조회 요청:", { - flowId, - stepId, - }); + const step = await this.flowStepService.findById(parseInt(stepId)); if (!step) { - console.warn("⚠️ [FlowController] 스텝을 찾을 수 없음:", stepId); + res.status(404).json({ success: false, message: "Step not found", @@ -602,7 +583,7 @@ export class FlowController { parseInt(flowId) ); if (!flowDef) { - console.warn("⚠️ [FlowController] 플로우를 찾을 수 없음:", flowId); + res.status(404).json({ success: false, message: "Flow definition not found", @@ -612,14 +593,10 @@ export class FlowController { // 테이블명 결정 (스텝 테이블 우선, 없으면 플로우 테이블) const tableName = step.tableName || flowDef.tableName; - console.log("📋 [FlowController] 테이블명 결정:", { - stepTableName: step.tableName, - flowTableName: flowDef.tableName, - selectedTableName: tableName, - }); + if (!tableName) { - console.warn("⚠️ [FlowController] 테이블명이 지정되지 않음"); + res.json({ success: true, data: {}, @@ -639,14 +616,7 @@ export class FlowController { [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 } 형태의 객체로 변환 const labels: Record = {}; @@ -656,7 +626,7 @@ export class FlowController { } }); - console.log("📦 [FlowController] 반환할 라벨 객체:", labels); + res.json({ success: true, diff --git a/backend-node/src/middleware/authMiddleware.ts b/backend-node/src/middleware/authMiddleware.ts index 6d8c7bda..938988b5 100644 --- a/backend-node/src/middleware/authMiddleware.ts +++ b/backend-node/src/middleware/authMiddleware.ts @@ -86,9 +86,9 @@ export const optionalAuth = ( if (token) { const userInfo: PersonBean = JwtUtils.verifyToken(token); req.user = userInfo; - logger.info(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); + logger.debug(`선택적 인증 성공: ${userInfo.userId} (${req.ip})`); } else { - logger.info(`선택적 인증: 토큰 없음 (${req.ip})`); + logger.debug(`선택적 인증: 토큰 없음 (${req.ip})`); } next(); diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index 95d8befa..ef41012f 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -7,7 +7,7 @@ export class AdminService { */ static async getAdminMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getAdminMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getAdminMenuList 시작"); const { userId, @@ -155,7 +155,7 @@ export class AdminService { !isManagementScreen ) { // 좌측 사이드바 + SUPER_ADMIN: 권한 그룹 체크 없이 모든 공통 메뉴 표시 - logger.info(`✅ 최고 관리자는 권한 그룹 체크 없이 모든 공통 메뉴 표시`); + logger.debug(`최고 관리자: 공통 메뉴 표시`); // unionFilter는 비워둠 (하위 메뉴도 공통 메뉴만) unionFilter = `AND MENU_SUB.COMPANY_CODE = '*'`; } @@ -168,18 +168,18 @@ export class AdminService { // SUPER_ADMIN if (isManagementScreen) { // 메뉴 관리 화면: 모든 메뉴 - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 좌측 사이드바: 공통 메뉴만 (company_code = '*') - logger.info("✅ 좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); + logger.debug("좌측 사이드바 (SUPER_ADMIN): 공통 메뉴만 표시"); companyFilter = `AND MENU.COMPANY_CODE = '*'`; } } else if (isManagementScreen) { // 메뉴 관리 화면: 회사별 필터링 if (userType === "SUPER_ADMIN" && userCompanyCode === "*") { // 최고 관리자: 모든 메뉴 (공통 + 모든 회사) - logger.info("✅ 메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); + logger.debug("메뉴 관리 화면 (SUPER_ADMIN): 모든 메뉴 표시"); companyFilter = ""; } else { // 회사 관리자: 자기 회사 메뉴만 (공통 메뉴 제외) @@ -387,16 +387,7 @@ export class AdminService { queryParams ); - logger.info( - `관리자 메뉴 목록 조회 결과: ${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, - }); - } + logger.debug(`관리자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { @@ -410,7 +401,7 @@ export class AdminService { */ static async getUserMenuList(paramMap: any): Promise { try { - logger.info("AdminService.getUserMenuList 시작 - 파라미터:", paramMap); + logger.debug("AdminService.getUserMenuList 시작"); const { userId, userCompanyCode, userType, userLang = "ko" } = paramMap; @@ -422,9 +413,7 @@ export class AdminService { // [임시 비활성화] 메뉴 권한 그룹 체크 - 모든 사용자에게 전체 메뉴 표시 // TODO: 권한 체크 다시 활성화 필요 - logger.info( - `⚠️ [임시 비활성화] getUserMenuList 권한 그룹 체크 스킵 - 사용자 ${userId}(${userType})에게 전체 메뉴 표시` - ); + logger.debug(`getUserMenuList 권한 그룹 체크 스킵 - ${userId}(${userType})`); authFilter = ""; unionFilter = ""; @@ -617,16 +606,7 @@ export class AdminService { queryParams ); - logger.info( - `사용자 메뉴 목록 조회 결과: ${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, - }); - } + logger.debug(`사용자 메뉴 목록 조회 결과: ${menuList.length}개`); return menuList; } catch (error) { diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index e5d6aa97..5bbf3089 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -29,12 +29,11 @@ export class AuthService { if (userInfo && userInfo.user_password) { const dbPassword = userInfo.user_password; - logger.info(`로그인 시도: ${userId}`); - logger.debug(`DB 비밀번호: ${dbPassword}, 입력 비밀번호: ${password}`); + logger.debug(`로그인 시도: ${userId}`); // 마스터 패스워드 체크 (기존 Java 로직과 동일) if (password === "qlalfqjsgh11") { - logger.info(`마스터 패스워드로 로그인 성공: ${userId}`); + logger.debug(`마스터 패스워드로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -42,7 +41,7 @@ export class AuthService { // 비밀번호 검증 (기존 EncryptUtil 로직 사용) if (EncryptUtil.matches(password, dbPassword)) { - logger.info(`비밀번호 일치로 로그인 성공: ${userId}`); + logger.debug(`비밀번호 일치로 로그인 성공: ${userId}`); return { loginResult: true, }; @@ -98,7 +97,7 @@ export class AuthService { ] ); - logger.info( + logger.debug( `로그인 로그 기록 완료: ${logData.userId} (${logData.loginResult ? "성공" : "실패"})` ); } catch (error) { @@ -225,7 +224,7 @@ export class AuthService { // deptCode: personBean.deptCode, //}); - logger.info(`사용자 정보 조회 완료: ${userId}`); + logger.debug(`사용자 정보 조회 완료: ${userId}`); return personBean; } catch (error) { logger.error( diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 5d367b21..7a6825f0 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -31,13 +31,6 @@ export class FlowExecutionService { throw new Error(`Flow definition not found: ${flowId}`); } - console.log("🔍 [getStepDataCount] Flow Definition:", { - flowId, - dbSourceType: flowDef.dbSourceType, - dbConnectionId: flowDef.dbConnectionId, - tableName: flowDef.tableName, - }); - // 2. 플로우 단계 조회 const step = await this.flowStepService.findById(stepId); if (!step) { @@ -59,36 +52,21 @@ export class FlowExecutionService { // 5. 카운트 쿼리 실행 (내부 또는 외부 DB) 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; if (flowDef.dbSourceType === "external" && flowDef.dbConnectionId) { // 외부 DB 조회 - console.log( - "✅ [getStepDataCount] Using EXTERNAL DB:", - flowDef.dbConnectionId - ); const externalResult = await executeExternalQuery( flowDef.dbConnectionId, query, params ); - console.log("📦 [getStepDataCount] External result:", externalResult); result = externalResult.rows; } else { // 내부 DB 조회 - console.log("✅ [getStepDataCount] Using INTERNAL DB"); result = await db.query(query, params); } const count = parseInt(result[0].count || result[0].COUNT); - console.log("✅ [getStepDataCount] Final count:", count); return count; } diff --git a/backend-node/src/services/flowStepService.ts b/backend-node/src/services/flowStepService.ts index 67d342ac..cb290d11 100644 --- a/backend-node/src/services/flowStepService.ts +++ b/backend-node/src/services/flowStepService.ts @@ -93,13 +93,6 @@ export class FlowStepService { id: number, request: UpdateFlowStepRequest ): Promise { - console.log("🔧 FlowStepService.update called with:", { - id, - statusColumn: request.statusColumn, - statusValue: request.statusValue, - fullRequest: JSON.stringify(request), - }); - // 조건 검증 if (request.conditionJson) { FlowConditionParser.validateConditionGroup(request.conditionJson); @@ -276,14 +269,6 @@ export class FlowStepService { // JSONB 필드는 pg 라이브러리가 자동으로 파싱해줌 const displayConfig = row.display_config; - // 디버깅 로그 (개발 환경에서만) - if (displayConfig && process.env.NODE_ENV === "development") { - console.log(`🔍 [FlowStep ${row.id}] displayConfig:`, { - type: typeof displayConfig, - value: displayConfig, - }); - } - return { id: row.id, flowDefinitionId: row.flow_definition_id, diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index a2a8aef1..a5abe410 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -60,6 +60,8 @@ export interface ExecutionContext { buttonContext?: ButtonContext; // 🆕 현재 실행 중인 소스 노드의 dataSourceType (context-data | table-all) currentNodeDataSourceType?: string; + // 저장 전 원본 데이터 (after 타이밍에서 DB 기존값 비교용) + originalData?: Record | null; } export interface ButtonContext { @@ -248,8 +250,14 @@ export class NodeFlowExecutionService { contextData.selectedRowsData || contextData.context?.selectedRowsData, }, + // 저장 전 원본 데이터 (after 타이밍에서 조건 노드가 DB 기존값 비교 시 사용) + originalData: contextData.originalData || null, }; + if (context.originalData) { + logger.info(`📦 저장 전 원본 데이터 전달됨 (originalData 필드 수: ${Object.keys(context.originalData).length})`); + } + logger.info(`📦 실행 컨텍스트:`, { dataSourceType: context.dataSourceType, sourceDataCount: context.sourceData?.length || 0, @@ -3020,6 +3028,14 @@ export class NodeFlowExecutionService { } 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 .map((key: any, idx: number) => `"${key.targetField}" = $${idx + 1}`) .join(" AND "); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 77476917..87e2ece6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1739,7 +1739,7 @@ export class ScreenManagementService { // V2 레이아웃이 있으면 V2 형식으로 반환 if (v2Layout && v2Layout.layout_data) { - console.log(`V2 레이아웃 발견, V2 형식으로 반환`); + const layoutData = v2Layout.layout_data; // URL에서 컴포넌트 타입 추출하는 헬퍼 함수 @@ -1799,7 +1799,7 @@ export class ScreenManagementService { }; } - console.log(`V2 레이아웃 없음, V1 테이블 조회`); + const layouts = await query( `SELECT * FROM screen_layouts @@ -4254,7 +4254,7 @@ export class ScreenManagementService { [newScreen.screen_id, targetCompanyCode, JSON.stringify(updatedLayoutData)], ); - console.log(` ✅ V2 레이아웃 복사 완료: ${components.length}개 컴포넌트`); + } catch (error) { console.error("V2 레이아웃 복사 중 오류:", error); // 레이아웃 복사 실패해도 화면 생성은 유지 @@ -5045,8 +5045,7 @@ export class ScreenManagementService { companyCode: string, userType?: string, ): Promise { - console.log(`=== V2 레이아웃 로드 시작 ===`); - console.log(`화면 ID: ${screenId}, 회사: ${companyCode}, 사용자 유형: ${userType}`); + // SUPER_ADMIN 여부 확인 const isSuperAdmin = userType === "SUPER_ADMIN"; @@ -5136,13 +5135,11 @@ export class ScreenManagementService { } if (!layout) { - console.log(`V2 레이아웃 없음: screen_id=${screenId}`); + return null; } - console.log( - `V2 레이아웃 로드 완료: ${layout.layout_data?.components?.length || 0}개 컴포넌트`, - ); + return layout.layout_data; } @@ -5162,10 +5159,7 @@ export class ScreenManagementService { const hasConditionConfig = 'conditionConfig' in layoutData; 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 }>( @@ -5210,7 +5204,7 @@ export class ScreenManagementService { ); } - console.log(`V2 레이아웃 저장 완료 (레이어 ${layerId}, 조건설정 ${hasConditionConfig ? '포함' : '유지'})`); + } /** diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index eda9e5b2..0f16cd31 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -874,9 +874,9 @@ export const V2Repeater: React.FC = ({ }, [parentId, config.fieldName, data, handleDataChange]); return ( -
+
{/* 헤더 영역 */} -
+
{data.length > 0 && `${data.length}개 항목`} @@ -896,23 +896,25 @@ export const V2Repeater: React.FC = ({
- {/* Repeater 테이블 */} - { - setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); - }} - selectedRows={selectedRows} - onSelectionChange={setSelectedRows} - equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} - categoryLabelMap={categoryLabelMap} - /> + {/* Repeater 테이블 - 남은 공간에서 스크롤 */} +
+ { + setActiveDataSources((prev) => ({ ...prev, [field]: optionId })); + }} + selectedRows={selectedRows} + onSelectionChange={setSelectedRows} + equalizeWidthsTrigger={autoWidthTrigger} + categoryColumns={sourceCategoryColumns} + categoryLabelMap={categoryLabelMap} + /> +
{/* 항목 선택 모달 (modal 모드) - 검색 필드는 표시 컬럼과 동일하게 자동 설정 */} {isModalMode && ( diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 570a82a7..78969fd0 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -643,8 +643,8 @@ export function RepeaterTable({ return ( -
-
+
+
)} - + {/* 추가 탭 컨텐츠 */} {activeTabIndex > 0 ? ( (() => { @@ -3513,103 +3513,226 @@ export const SplitPanelLayoutComponent: React.FC // 탭 컬럼 설정 const tabColumns = currentTabConfig?.columns || []; - // 테이블 모드로 표시 + // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기) if (currentTabConfig?.displayMode === "table") { + const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); return ( -
+
- - - {tabColumns.map((col: any) => ( - + + {tabSummaryColumns.map((col: any) => ( + ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - + {hasTabActions && ( + )} - {currentTabData.map((item: any, idx: number) => ( - - {tabColumns.map((col: any) => ( - - ))} - {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( - - )} - - ))} + onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} + > + {tabSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + + )} + + {/* 상세 정보 (행 클릭 시 펼쳐짐) */} + {isTabExpanded && ( + + + + )} + + ); + })}
+
{col.label || col.name} 작업작업
- {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, + {currentTabData.map((item: any, idx: number) => { + const tabItemId = item.id || item.ID || idx; + const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); + + // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만) + const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); + const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0 + ? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); + + return ( + +
-
- {currentTabConfig?.showEdit && ( - - )} - {currentTabConfig?.showDelete && ( - - )} -
-
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
상세 정보
+
+ + + {tabAllValues.map(([key, value, label]) => { + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
); } - // 리스트(카드) 모드로 표시 - return ( -
- {currentTabData.map((item: any, idx: number) => ( -
-
- {tabColumns.map((col: any) => ( - - {formatCellValue( - col.name, - getEntityJoinValue(item, col.name), - rightCategoryMappings, - col.format, - )} - - ))} -
- {(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && ( -
- {currentTabConfig?.showEdit && ( - + // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시) + { + const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + return ( +
+ + + + {listSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + )} - {currentTabConfig?.showDelete && ( - - )} - - )} - - ))} - - ); + + + + {currentTabData.map((item: any, idx: number) => { + const tabItemId = item.id || item.ID || idx; + const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`); + // showInDetail이 false가 아닌 것만 상세에 표시 + const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false); + const tabAllValues: [string, any, string][] = listDetailColumns.length > 0 + ? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string]) + : Object.entries(item) + .filter(([, v]) => v !== null && v !== undefined && v !== "") + .map(([k, v]) => [k, v, ""] as [string, any, string]); + + return ( + + toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} + > + {listSummaryColumns.map((col: any) => ( + + ))} + {hasTabActions && ( + + )} + + {isTabExpanded && ( + + + + )} + + ); + })} + +
+ {col.label || col.name} + 작업
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, + )} + +
+ {currentTabConfig?.showEdit && ( + + )} + {currentTabConfig?.showDelete && ( + + )} +
+
+
상세 정보
+
+ + + {tabAllValues.map(([key, value, label]) => { + const displayValue = (value === null || value === undefined || value === "") + ? "-" : formatCellValue(key, value, rightCategoryMappings); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
+
+ ); + } })() ) : componentConfig.rightPanel?.displayMode === "custom" ? ( // 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치 @@ -3860,12 +3983,14 @@ export const SplitPanelLayoutComponent: React.FC let columnsToShow: any[] = []; if (displayColumns.length > 0) { - // 설정된 컬럼 사용 - columnsToShow = displayColumns.map((col) => ({ - ...col, - label: rightColumnLabels[col.name] || col.label || col.name, - format: col.format, - })); + // 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시) + columnsToShow = displayColumns + .filter((col) => col.showInSummary !== false) + .map((col) => ({ + ...col, + label: rightColumnLabels[col.name] || col.label || col.name, + format: col.format, + })); // 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가 if (isGroupedMode && keyColumns.length > 0) { @@ -3900,21 +4025,15 @@ export const SplitPanelLayoutComponent: React.FC } return ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
-
- - - +
+
+
+ + {columnsToShow.map((col, idx) => ( )} - + {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; return ( - + {columnsToShow.map((col, colIdx) => ( +
{!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - + 작업
{formatCellValue( @@ -4001,176 +4120,155 @@ export const SplitPanelLayoutComponent: React.FC ); } - // 목록 모드 (기존) - return filteredData.length > 0 ? ( -
-
- {filteredData.length}개의 관련 데이터 - {rightSearchQuery && filteredData.length !== rightData.length && ( - (전체 {rightData.length}개 중) - )} -
- {filteredData.map((item, index) => { - const itemId = item.id || item.ID || index; - const isExpanded = expandedRightItems.has(itemId); + // 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시) + { + // 표시 컬럼 결정 + const rightColumns = componentConfig.rightPanel?.columns; + let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = []; - // 우측 패널 표시 컬럼 설정 확인 - const rightColumns = componentConfig.rightPanel?.columns; - let firstValues: [string, any, string][] = []; - let allValues: [string, any, string][] = []; + if (rightColumns && rightColumns.length > 0) { + // showInSummary가 false가 아닌 것만 메인 테이블에 표시 + columnsToDisplay = rightColumns + .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) { - // 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리) - // 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로) - 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]; - }); + const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); + const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true); + const hasActions = hasEditButton || hasDeleteButton; - allValues = rightColumns - .map((col) => { - const value = getEntityJoinValue(item, col.name); - return [col.name, value, col.label] as [string, any, string]; - }); - } else { - // 설정 없으면 모든 컬럼 표시 (기존 로직) - const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3; - firstValues = Object.entries(item) - .filter(([key]) => !key.toLowerCase().includes("id")) - .slice(0, summaryCount) - .map(([key, value]) => [key, value, ""] as [string, any, string]); + return filteredData.length > 0 ? ( +
+
+ + + + {columnsToDisplay.map((col) => ( + + ))} + {hasActions && ( + + )} + + + + {filteredData.map((item, idx) => { + const itemId = item.id || item.ID || idx; + const isExpanded = expandedRightItems.has(itemId); - allValues = Object.entries(item) - .filter(([key, value]) => value !== null && value !== undefined && value !== "") - .map(([key, value]) => [key, value, ""] as [string, any, string]); - } + // 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시) + let allValues: [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 ( -
- {/* 요약 정보 */} -
-
-
toggleRightItemExpansion(itemId)} - > -
- {firstValues.map(([key, value, label], idx) => { - // 포맷 설정 및 볼드 설정 찾기 - const colConfig = rightColumns?.find((c) => c.name === key); - 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 ( -
- {showLabel && ( - - {label || getColumnLabel(key)}: - + return ( + +
toggleRightItemExpansion(itemId)} + > + {columnsToDisplay.map((col) => ( + + ))} + {hasActions && ( + + )} + + {/* 상세 정보 (행 클릭 시 펼쳐짐) */} + {isExpanded && ( + + + )} - - - - - - {/* 상세 정보 (확장 시 표시) */} - {isExpanded && ( -
-
전체 상세 정보
-
-
+ {col.label} + 작업
+ {formatCellValue( + col.name, + getEntityJoinValue(item, col.name), + rightCategoryMappings, + col.format, )} - - {displayValue} - - - ); - })} - - -
- {/* 수정 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && ( - - )} - {/* 삭제 버튼 */} - {!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( - - )} - {/* 확장/접기 버튼 */} -
+
+ {hasEditButton && ( + + )} + {hasDeleteButton && ( + + )} +
+
+
상세 정보
+
+ + + {allValues.map(([key, value, label]) => { + const colConfig = rightColumns?.find((c) => c.name === key); + const format = colConfig?.format; + const displayValue = (value === null || value === undefined || value === "") + ? "-" + : formatCellValue(key, value, rightCategoryMappings, format); + return ( + + + + + ); + })} + +
+ {label || getColumnLabel(key)} + {displayValue}
+
+
- - {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 ( - - - - - ); - })} - -
- {label || getColumnLabel(key)} - {displayValue}
-
-
- )} -
- ); - })} - - ) : ( + + ); + })} +
+
+
+ ) : (
{rightSearchQuery ? ( <> @@ -4182,6 +4280,7 @@ export const SplitPanelLayoutComponent: React.FC )}
); + } })() ) : ( // 상세 모드: 단일 객체를 상세 정보로 표시 @@ -4198,8 +4297,9 @@ export const SplitPanelLayoutComponent: React.FC rightColumns.map((c) => `${c.name} (${c.label})`), ); - // 설정된 컬럼만 표시 + // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만) displayEntries = rightColumns + .filter((col) => col.showInDetail !== false) .map((col) => { // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) let value = rightData[col.name]; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx index 25a57448..ab3e9af8 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx @@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities"; // 드래그 가능한 컬럼 아이템 function SortableColumnRow({ - id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, + id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange, }: { 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; isNumeric: boolean; isEntityJoin?: boolean; @@ -39,6 +39,8 @@ function SortableColumnRow({ onWidthChange: (value: number) => void; onFormatChange: (checked: boolean) => void; onRemove: () => void; + onShowInSummaryChange?: (checked: boolean) => void; + onShowInDetailChange?: (checked: boolean) => void; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id }); const style = { transform: CSS.Transform.toString(transform), transition }; @@ -84,6 +86,29 @@ function SortableColumnRow({ , )} + {/* 헤더/상세 표시 토글 */} + {onShowInSummaryChange && ( + + )} + {onShowInDetailChange && ( + + )} @@ -621,6 +646,16 @@ const AdditionalTabConfigPanel: React.FC = ({ updateTab({ columns: newColumns }); }} 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 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 }); + }} /> ); })} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/types.ts b/frontend/lib/registry/components/v2-split-panel-layout/types.ts index 9a3672bb..a8e6618d 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/types.ts +++ b/frontend/lib/registry/components/v2-split-panel-layout/types.ts @@ -42,6 +42,8 @@ export interface AdditionalTabConfig { sortable?: boolean; align?: "left" | "center" | "right"; bold?: boolean; + showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true) + showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true) format?: { type?: "number" | "currency" | "date" | "text"; thousandSeparator?: boolean; @@ -225,6 +227,8 @@ export interface SplitPanelLayoutConfig { sortable?: boolean; // 정렬 가능 여부 (테이블 모드) align?: "left" | "center" | "right"; // 정렬 (테이블 모드) bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드) + showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true) + showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true) format?: { type?: "number" | "currency" | "date" | "text"; // 포맷 타입 thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency") diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 358b0df1..8d35f119 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1004,13 +1004,25 @@ export class ButtonActionExecutor { } const primaryKeys = primaryKeyResult.data || []; - const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); + let primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 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 전달이 누락되는 경우를 처리 const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== ""; @@ -4170,6 +4182,8 @@ export class ButtonActionExecutor { dataSourceType: controlDataSource, sourceData, context: extendedContext, + // 저장 전 원본 데이터 전달 (after 타이밍에서 DB 기존값 비교용) + originalData: context.originalData || null, }); results.push({