diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 7b3b1033..f2f2f3ee 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1120,8 +1120,8 @@ export async function saveMenu( `INSERT INTO menu_info ( objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng, seq, menu_url, menu_desc, writer, regdate, status, - system_name, company_code, lang_key, lang_key_desc, screen_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + system_name, company_code, lang_key, lang_key_desc, screen_code, menu_icon + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *`, [ objid, @@ -1140,6 +1140,7 @@ export async function saveMenu( menuData.langKey || null, menuData.langKeyDesc || null, screenCode, + menuData.menuIcon || null, ] ); @@ -1323,8 +1324,9 @@ export async function updateMenu( company_code = $10, lang_key = $11, lang_key_desc = $12, - screen_code = $13 - WHERE objid = $14 + screen_code = $13, + menu_icon = $14 + WHERE objid = $15 RETURNING *`, [ menuData.menuType ? Number(menuData.menuType) : null, @@ -1340,6 +1342,7 @@ export async function updateMenu( menuData.langKey || null, menuData.langKeyDesc || null, screenCode, + menuData.menuIcon || null, Number(menuId), ] ); diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index 4a6a1e03..c8041749 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -9,6 +9,7 @@ import { FlowStepService } from "../services/flowStepService"; import { FlowConnectionService } from "../services/flowConnectionService"; import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowDataMoveService } from "../services/flowDataMoveService"; +import { FlowProcedureService } from "../services/flowProcedureService"; export class FlowController { private flowDefinitionService: FlowDefinitionService; @@ -16,6 +17,7 @@ export class FlowController { private flowConnectionService: FlowConnectionService; private flowExecutionService: FlowExecutionService; private flowDataMoveService: FlowDataMoveService; + private flowProcedureService: FlowProcedureService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); @@ -23,6 +25,7 @@ export class FlowController { this.flowConnectionService = new FlowConnectionService(); this.flowExecutionService = new FlowExecutionService(); this.flowDataMoveService = new FlowDataMoveService(); + this.flowProcedureService = new FlowProcedureService(); } // ==================== 플로우 정의 ==================== @@ -144,8 +147,9 @@ export class FlowController { try { const { id } = req.params; const flowId = parseInt(id); + const userCompanyCode = (req as any).user?.companyCode; - const definition = await this.flowDefinitionService.findById(flowId); + const definition = await this.flowDefinitionService.findById(flowId, userCompanyCode); if (!definition) { res.status(404).json({ success: false, @@ -182,12 +186,13 @@ export class FlowController { const { id } = req.params; const flowId = parseInt(id); const { name, description, isActive } = req.body; + const userCompanyCode = (req as any).user?.companyCode; const flowDef = await this.flowDefinitionService.update(flowId, { name, description, isActive, - }); + }, userCompanyCode); if (!flowDef) { res.status(404).json({ @@ -217,8 +222,9 @@ export class FlowController { try { const { id } = req.params; const flowId = parseInt(id); + const userCompanyCode = (req as any).user?.companyCode; - const success = await this.flowDefinitionService.delete(flowId); + const success = await this.flowDefinitionService.delete(flowId, userCompanyCode); if (!success) { res.status(404).json({ @@ -275,6 +281,7 @@ export class FlowController { try { const { flowId } = req.params; const flowDefinitionId = parseInt(flowId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -293,6 +300,16 @@ export class FlowController { return; } + // 플로우 소유권 검증 + const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found or access denied", + }); + return; + } + const step = await this.flowStepService.create({ flowDefinitionId, stepName, @@ -324,6 +341,7 @@ export class FlowController { try { const { stepId } = req.params; const id = parseInt(stepId); + const userCompanyCode = (req as any).user?.companyCode; const { stepName, stepOrder, @@ -342,6 +360,19 @@ export class FlowController { displayConfig, } = req.body; + // 스텝 소유권 검증: 스텝이 속한 플로우가 사용자 회사 소유인지 확인 + const existingStep = await this.flowStepService.findById(id); + if (existingStep) { + const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } + const step = await this.flowStepService.update(id, { stepName, stepOrder, @@ -388,6 +419,20 @@ export class FlowController { try { const { stepId } = req.params; const id = parseInt(stepId); + const userCompanyCode = (req as any).user?.companyCode; + + // 스텝 소유권 검증 + const existingStep = await this.flowStepService.findById(id); + if (existingStep) { + const flowDef = await this.flowDefinitionService.findById(existingStep.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } const success = await this.flowStepService.delete(id); @@ -446,6 +491,7 @@ export class FlowController { createConnection = async (req: Request, res: Response): Promise => { try { const { flowDefinitionId, fromStepId, toStepId, label } = req.body; + const userCompanyCode = (req as any).user?.companyCode; if (!flowDefinitionId || !fromStepId || !toStepId) { res.status(400).json({ @@ -455,6 +501,28 @@ export class FlowController { return; } + // 플로우 소유권 검증 + const flowDef = await this.flowDefinitionService.findById(flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(404).json({ + success: false, + message: "Flow definition not found or access denied", + }); + return; + } + + // fromStepId, toStepId가 해당 flow에 속하는지 검증 + const fromStep = await this.flowStepService.findById(fromStepId); + const toStep = await this.flowStepService.findById(toStepId); + if (!fromStep || fromStep.flowDefinitionId !== flowDefinitionId || + !toStep || toStep.flowDefinitionId !== flowDefinitionId) { + res.status(400).json({ + success: false, + message: "fromStepId and toStepId must belong to the specified flow", + }); + return; + } + const connection = await this.flowConnectionService.create({ flowDefinitionId, fromStepId, @@ -482,6 +550,20 @@ export class FlowController { try { const { connectionId } = req.params; const id = parseInt(connectionId); + const userCompanyCode = (req as any).user?.companyCode; + + // 연결 소유권 검증 + const existingConn = await this.flowConnectionService.findById(id); + if (existingConn) { + const flowDef = await this.flowDefinitionService.findById(existingConn.flowDefinitionId, userCompanyCode); + if (!flowDef) { + res.status(403).json({ + success: false, + message: "Access denied: flow does not belong to your company", + }); + return; + } + } const success = await this.flowConnectionService.delete(id); @@ -670,23 +752,24 @@ export class FlowController { */ moveData = async (req: Request, res: Response): Promise => { try { - const { flowId, recordId, toStepId, note } = req.body; + const { flowId, fromStepId, recordId, toStepId, note } = req.body; const userId = (req as any).user?.userId || "system"; - if (!flowId || !recordId || !toStepId) { + if (!flowId || !fromStepId || !recordId || !toStepId) { res.status(400).json({ success: false, - message: "flowId, recordId, and toStepId are required", + message: "flowId, fromStepId, recordId, and toStepId are required", }); return; } await this.flowDataMoveService.moveDataToStep( flowId, - recordId, + fromStepId, toStepId, + recordId, userId, - note + note ? { note } : undefined ); res.json({ @@ -856,4 +939,94 @@ export class FlowController { }); } }; + + // ==================== 프로시저/함수 ==================== + + /** + * 프로시저/함수 목록 조회 + */ + listProcedures = async (req: Request, res: Response): Promise => { + try { + const dbSource = (req.query.dbSource as string) || "internal"; + const connectionId = req.query.connectionId + ? parseInt(req.query.connectionId as string) + : undefined; + const schema = req.query.schema as string | undefined; + + if (dbSource !== "internal" && dbSource !== "external") { + res.status(400).json({ + success: false, + message: "dbSource는 internal 또는 external이어야 합니다", + }); + return; + } + + if (dbSource === "external" && !connectionId) { + res.status(400).json({ + success: false, + message: "외부 DB 조회 시 connectionId가 필요합니다", + }); + return; + } + + const procedures = await this.flowProcedureService.listProcedures( + dbSource, + connectionId, + schema + ); + + res.json({ success: true, data: procedures }); + } catch (error: any) { + console.error("프로시저 목록 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "프로시저 목록 조회에 실패했습니다", + }); + } + }; + + /** + * 프로시저/함수 파라미터 조회 + */ + getProcedureParameters = async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + const dbSource = (req.query.dbSource as string) || "internal"; + const connectionId = req.query.connectionId + ? parseInt(req.query.connectionId as string) + : undefined; + const schema = req.query.schema as string | undefined; + + if (!name) { + res.status(400).json({ + success: false, + message: "프로시저 이름이 필요합니다", + }); + return; + } + + if (dbSource !== "internal" && dbSource !== "external") { + res.status(400).json({ + success: false, + message: "dbSource는 internal 또는 external이어야 합니다", + }); + return; + } + + const parameters = await this.flowProcedureService.getProcedureParameters( + name, + dbSource as "internal" | "external", + connectionId, + schema + ); + + res.json({ success: true, data: parameters }); + } catch (error: any) { + console.error("프로시저 파라미터 조회 실패:", error); + res.status(500).json({ + success: false, + message: error.message || "프로시저 파라미터 조회에 실패했습니다", + }); + } + }; } diff --git a/backend-node/src/routes/flowRoutes.ts b/backend-node/src/routes/flowRoutes.ts index e33afac2..0f4518e7 100644 --- a/backend-node/src/routes/flowRoutes.ts +++ b/backend-node/src/routes/flowRoutes.ts @@ -50,4 +50,8 @@ router.put("/:flowId/step/:stepId/data/:recordId", flowController.updateStepData router.get("/audit/:flowId/:recordId", flowController.getAuditLogs); router.get("/audit/:flowId", flowController.getFlowAuditLogs); +// ==================== 프로시저/함수 ==================== +router.get("/procedures", flowController.listProcedures); +router.get("/procedures/:name/parameters", flowController.getProcedureParameters); + export default router; diff --git a/backend-node/src/services/adminService.ts b/backend-node/src/services/adminService.ts index ef41012f..e5d0c1a0 100644 --- a/backend-node/src/services/adminService.ts +++ b/backend-node/src/services/adminService.ts @@ -227,7 +227,8 @@ export class AdminService { PATH, CYCLE, TRANSLATED_NAME, - TRANSLATED_DESC + TRANSLATED_DESC, + MENU_ICON ) AS ( SELECT 1 AS LEVEL, @@ -282,7 +283,8 @@ export class AdminService { AND MLT.lang_code = $1 LIMIT 1), MENU.MENU_DESC - ) + ), + MENU.MENU_ICON FROM MENU_INFO MENU WHERE ${menuTypeCondition} AND ${statusCondition} @@ -348,7 +350,8 @@ export class AdminService { AND MLT.lang_code = $1 LIMIT 1), MENU_SUB.MENU_DESC - ) + ), + MENU_SUB.MENU_ICON FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.OBJID != ANY(V_MENU.PATH) @@ -374,6 +377,7 @@ export class AdminService { COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, A.TRANSLATED_NAME, A.TRANSLATED_DESC, + A.MENU_ICON, CASE UPPER(A.STATUS) WHEN 'ACTIVE' THEN '활성화' WHEN 'INACTIVE' THEN '비활성화' @@ -514,7 +518,8 @@ export class AdminService { LANG_KEY, LANG_KEY_DESC, PATH, - CYCLE + CYCLE, + MENU_ICON ) AS ( SELECT 1 AS LEVEL, @@ -532,7 +537,8 @@ export class AdminService { LANG_KEY, LANG_KEY_DESC, ARRAY [MENU.OBJID], - FALSE + FALSE, + MENU.MENU_ICON FROM MENU_INFO MENU WHERE PARENT_OBJ_ID = 0 AND MENU_TYPE = 1 @@ -558,7 +564,8 @@ export class AdminService { MENU_SUB.LANG_KEY, MENU_SUB.LANG_KEY_DESC, PATH || MENU_SUB.SEQ::numeric, - MENU_SUB.OBJID = ANY(PATH) + MENU_SUB.OBJID = ANY(PATH), + MENU_SUB.MENU_ICON FROM MENU_INFO MENU_SUB JOIN V_MENU ON MENU_SUB.PARENT_OBJ_ID = V_MENU.OBJID WHERE MENU_SUB.STATUS = 'active' @@ -584,10 +591,9 @@ export class AdminService { A.COMPANY_CODE, A.LANG_KEY, A.LANG_KEY_DESC, + A.MENU_ICON, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME, - -- 번역된 메뉴명 (우선순위: 번역 > 기본명) COALESCE(MLT_NAME.lang_text, A.MENU_NAME_KOR) AS TRANSLATED_NAME, - -- 번역된 설명 (우선순위: 번역 > 기본명) COALESCE(MLT_DESC.lang_text, A.MENU_DESC) AS TRANSLATED_DESC, CASE UPPER(A.STATUS) WHEN 'ACTIVE' THEN '활성화' diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index 4c24e206..1b183074 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -210,19 +210,62 @@ export class DynamicFormService { } } + /** + * VIEW인 경우 원본(base) 테이블명을 반환, 일반 테이블이면 그대로 반환 + */ + async resolveBaseTable(tableName: string): Promise { + try { + const result = await query<{ table_type: string }>( + `SELECT table_type FROM information_schema.tables + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (result.length === 0 || result[0].table_type !== 'VIEW') { + return tableName; + } + + // VIEW의 FROM 절에서 첫 번째 테이블을 추출 + const viewDef = await query<{ view_definition: string }>( + `SELECT view_definition FROM information_schema.views + WHERE table_name = $1 AND table_schema = 'public'`, + [tableName] + ); + + if (viewDef.length > 0) { + const definition = viewDef[0].view_definition; + // PostgreSQL은 뷰 정의를 "FROM (테이블명 별칭 LEFT JOIN ...)" 형태로 저장 + const fromMatch = definition.match(/FROM\s+\(?(?:public\.)?(\w+)\s/i); + if (fromMatch) { + const baseTable = fromMatch[1]; + console.log(`🔄 VIEW ${tableName} → 원본 테이블 ${baseTable} 으로 전환`); + return baseTable; + } + } + + return tableName; + } catch (error) { + console.error(`❌ VIEW 원본 테이블 조회 실패:`, error); + return tableName; + } + } + /** * 폼 데이터 저장 (실제 테이블에 직접 저장) */ async saveFormData( screenId: number, - tableName: string, + tableNameInput: string, data: Record, ipAddress?: string ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("💾 서비스: 실제 테이블에 폼 데이터 저장 시작:", { screenId, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -813,14 +856,17 @@ export class DynamicFormService { */ async updateFormDataPartial( id: string | number, // 🔧 UUID 문자열도 지원 - tableName: string, + tableNameInput: string, originalData: Record, newData: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 부분 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, originalData, newData, }); @@ -1008,13 +1054,16 @@ export class DynamicFormService { */ async updateFormData( id: string | number, - tableName: string, + tableNameInput: string, data: Record ): Promise { + // VIEW인 경우 원본 테이블로 전환 + const tableName = await this.resolveBaseTable(tableNameInput); try { console.log("🔄 서비스: 실제 테이블에서 폼 데이터 업데이트 시작:", { id, tableName, + originalTable: tableNameInput !== tableName ? tableNameInput : undefined, data, }); @@ -1215,9 +1264,13 @@ export class DynamicFormService { screenId?: number ): Promise { try { + // VIEW인 경우 원본 테이블로 전환 (VIEW에는 기본키가 없으므로) + const actualTable = await this.resolveBaseTable(tableName); + console.log("🗑️ 서비스: 실제 테이블에서 폼 데이터 삭제 시작:", { id, - tableName, + tableName: actualTable, + originalTable: tableName !== actualTable ? tableName : undefined, }); // 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회 @@ -1235,15 +1288,15 @@ export class DynamicFormService { `; console.log("🔍 기본키 조회 SQL:", primaryKeyQuery); - console.log("🔍 테이블명:", tableName); + console.log("🔍 테이블명:", actualTable); const primaryKeyResult = await query<{ column_name: string; data_type: string; - }>(primaryKeyQuery, [tableName]); + }>(primaryKeyQuery, [actualTable]); if (!primaryKeyResult || primaryKeyResult.length === 0) { - throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}의 기본키를 찾을 수 없습니다.`); } const primaryKeyInfo = primaryKeyResult[0]; @@ -1275,7 +1328,7 @@ export class DynamicFormService { // 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성 const deleteQuery = ` - DELETE FROM ${tableName} + DELETE FROM ${actualTable} WHERE ${primaryKeyColumn} = $1${typeCastSuffix} RETURNING * `; @@ -1295,7 +1348,7 @@ export class DynamicFormService { // 삭제된 행이 없으면 레코드를 찾을 수 없는 것 if (!result || !Array.isArray(result) || result.length === 0) { - throw new Error(`테이블 ${tableName}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); + throw new Error(`테이블 ${actualTable}에서 ID '${id}'에 해당하는 레코드를 찾을 수 없습니다.`); } console.log("✅ 서비스: 실제 테이블 삭제 성공:", result); diff --git a/backend-node/src/services/flowConditionParser.ts b/backend-node/src/services/flowConditionParser.ts index 5f2e648a..c3a930ea 100644 --- a/backend-node/src/services/flowConditionParser.ts +++ b/backend-node/src/services/flowConditionParser.ts @@ -132,14 +132,23 @@ export class FlowConditionParser { /** * SQL 인젝션 방지를 위한 컬럼명 검증 */ - private static sanitizeColumnName(columnName: string): string { - // 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원) + static sanitizeColumnName(columnName: string): string { if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) { throw new Error(`Invalid column name: ${columnName}`); } return columnName; } + /** + * SQL 인젝션 방지를 위한 테이블명 검증 + */ + static sanitizeTableName(tableName: string): string { + if (!/^[a-zA-Z0-9_.]+$/.test(tableName)) { + throw new Error(`Invalid table name: ${tableName}`); + } + return tableName; + } + /** * 조건 검증 */ diff --git a/backend-node/src/services/flowDataMoveService.ts b/backend-node/src/services/flowDataMoveService.ts index 09058502..9fe0bbdb 100644 --- a/backend-node/src/services/flowDataMoveService.ts +++ b/backend-node/src/services/flowDataMoveService.ts @@ -25,16 +25,21 @@ import { buildInsertQuery, buildSelectQuery, } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; +import { FlowProcedureService } from "./flowProcedureService"; +import { FlowProcedureConfig } from "../types/flow"; export class FlowDataMoveService { private flowDefinitionService: FlowDefinitionService; private flowStepService: FlowStepService; private externalDbIntegrationService: FlowExternalDbIntegrationService; + private flowProcedureService: FlowProcedureService; constructor() { this.flowDefinitionService = new FlowDefinitionService(); this.flowStepService = new FlowStepService(); this.externalDbIntegrationService = new FlowExternalDbIntegrationService(); + this.flowProcedureService = new FlowProcedureService(); } /** @@ -89,6 +94,64 @@ export class FlowDataMoveService { let sourceTable = fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName; + // 1.5. 프로시저 호출 (스텝 이동 전 실행, 실패 시 전체 롤백) + if ( + toStep.integrationType === "procedure" && + toStep.integrationConfig && + (toStep.integrationConfig as FlowProcedureConfig).type === "procedure" + ) { + const procConfig = toStep.integrationConfig as FlowProcedureConfig; + // 레코드 데이터 조회 (파라미터 매핑용) + let recordData: Record = {}; + try { + const recordTable = FlowConditionParser.sanitizeTableName( + sourceTable || flowDefinition.tableName + ); + const recordResult = await client.query( + `SELECT * FROM ${recordTable} WHERE id = $1 LIMIT 1`, + [dataId] + ); + if (recordResult.rows && recordResult.rows.length > 0) { + recordData = recordResult.rows[0]; + } + } catch (err: any) { + console.warn("프로시저 파라미터용 레코드 조회 실패:", err.message); + } + + console.log(`프로시저 호출 시작: ${procConfig.procedureName}`, { + flowId, + fromStepId, + toStepId, + dataId, + dbSource: procConfig.dbSource, + }); + + const procResult = await this.flowProcedureService.executeProcedure( + procConfig, + recordData, + procConfig.dbSource === "internal" ? client : undefined + ); + + console.log(`프로시저 호출 완료: ${procConfig.procedureName}`, { + success: procResult.success, + }); + + // 프로시저 실행 로그 기록 + await this.logIntegration( + flowId, + toStep.id, + dataId, + "procedure", + procConfig.connectionId, + procConfig, + procResult.result, + "success", + undefined, + 0, + userId + ); + } + // 2. 이동 방식에 따라 처리 switch (toStep.moveType || "status") { case "status": @@ -236,18 +299,19 @@ export class FlowDataMoveService { ); } - const statusColumn = toStep.statusColumn; - const tableName = fromStep.tableName; + const statusColumn = FlowConditionParser.sanitizeColumnName(toStep.statusColumn); + const tableName = FlowConditionParser.sanitizeTableName(fromStep.tableName); // 추가 필드 업데이트 준비 const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`]; const values: any[] = [dataId, toStep.statusValue]; let paramIndex = 3; - // 추가 데이터가 있으면 함께 업데이트 + // 추가 데이터가 있으면 함께 업데이트 (키 검증 포함) if (additionalData) { for (const [key, value] of Object.entries(additionalData)) { - updates.push(`${key} = $${paramIndex}`); + const safeKey = FlowConditionParser.sanitizeColumnName(key); + updates.push(`${safeKey} = $${paramIndex}`); values.push(value); paramIndex++; } @@ -276,33 +340,38 @@ export class FlowDataMoveService { dataId: any, additionalData?: Record ): Promise { - const sourceTable = fromStep.tableName; - const targetTable = toStep.targetTable || toStep.tableName; + const sourceTable = FlowConditionParser.sanitizeTableName(fromStep.tableName); + const targetTable = FlowConditionParser.sanitizeTableName(toStep.targetTable || toStep.tableName); const fieldMappings = toStep.fieldMappings || {}; // 1. 소스 데이터 조회 const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`; const sourceResult = await client.query(selectQuery, [dataId]); - if (sourceResult.length === 0) { + if (sourceResult.rows.length === 0) { throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`); } - const sourceData = sourceResult[0]; + const sourceData = sourceResult.rows[0]; // 2. 필드 매핑 적용 const mappedData: Record = {}; - // 매핑 정의가 있으면 적용 + // 매핑 정의가 있으면 적용 (컬럼명 검증) for (const [sourceField, targetField] of Object.entries(fieldMappings)) { + FlowConditionParser.sanitizeColumnName(sourceField); + FlowConditionParser.sanitizeColumnName(targetField as string); if (sourceData[sourceField] !== undefined) { mappedData[targetField as string] = sourceData[sourceField]; } } - // 추가 데이터 병합 + // 추가 데이터 병합 (키 검증) if (additionalData) { - Object.assign(mappedData, additionalData); + for (const [key, value] of Object.entries(additionalData)) { + const safeKey = FlowConditionParser.sanitizeColumnName(key); + mappedData[safeKey] = value; + } } // 3. 타겟 테이블에 데이터 삽입 @@ -321,7 +390,7 @@ export class FlowDataMoveService { `; const insertResult = await client.query(insertQuery, values); - return insertResult[0].id; + return insertResult.rows[0].id; } /** @@ -349,12 +418,12 @@ export class FlowDataMoveService { ]); const stepDataMap: Record = - mappingResult.length > 0 ? mappingResult[0].step_data_map : {}; + mappingResult.rows.length > 0 ? mappingResult.rows[0].step_data_map : {}; // 새 단계 데이터 추가 stepDataMap[String(currentStepId)] = String(targetDataId); - if (mappingResult.length > 0) { + if (mappingResult.rows.length > 0) { // 기존 매핑 업데이트 const updateQuery = ` UPDATE flow_data_mapping @@ -366,7 +435,7 @@ export class FlowDataMoveService { await client.query(updateQuery, [ currentStepId, JSON.stringify(stepDataMap), - mappingResult[0].id, + mappingResult.rows[0].id, ]); } else { // 새 매핑 생성 @@ -596,18 +665,19 @@ export class FlowDataMoveService { } break; + case "procedure": + // 프로시저는 데이터 이동 전에 이미 실행됨 (step 1.5) + break; + case "rest_api": - // REST API 연동 (추후 구현) console.warn("REST API 연동은 아직 구현되지 않았습니다"); break; case "webhook": - // Webhook 연동 (추후 구현) console.warn("Webhook 연동은 아직 구현되지 않았습니다"); break; case "hybrid": - // 복합 연동 (추후 구현) console.warn("복합 연동은 아직 구현되지 않았습니다"); break; @@ -709,6 +779,40 @@ export class FlowDataMoveService { let sourceTable = fromStep.tableName; let targetTable = toStep.tableName || fromStep.tableName; + // 1.5. 프로시저 호출 (외부 DB 경로 - 스텝 이동 전) + if ( + toStep.integrationType === "procedure" && + toStep.integrationConfig && + (toStep.integrationConfig as FlowProcedureConfig).type === "procedure" + ) { + const procConfig = toStep.integrationConfig as FlowProcedureConfig; + let recordData: Record = {}; + try { + const recordTable = FlowConditionParser.sanitizeTableName( + sourceTable || "" + ); + if (recordTable) { + const placeholder = getPlaceholder(dbType, 1); + const recordResult = await externalClient.query( + `SELECT * FROM ${recordTable} WHERE id = ${placeholder}`, + [dataId] + ); + const rows = recordResult.rows || recordResult; + if (Array.isArray(rows) && rows.length > 0) { + recordData = rows[0]; + } + } + } catch (err: any) { + console.warn("프로시저 파라미터용 레코드 조회 실패 (외부):", err.message); + } + + await this.flowProcedureService.executeProcedure( + procConfig, + recordData, + procConfig.dbSource === "external" ? undefined : undefined + ); + } + // 2. 이동 방식에 따라 처리 switch (toStep.moveType || "status") { case "status": diff --git a/backend-node/src/services/flowDefinitionService.ts b/backend-node/src/services/flowDefinitionService.ts index 80c920ad..d43b2fe0 100644 --- a/backend-node/src/services/flowDefinitionService.ts +++ b/backend-node/src/services/flowDefinitionService.ts @@ -19,7 +19,8 @@ export class FlowDefinitionService { userId: string, userCompanyCode?: string ): Promise { - const companyCode = request.companyCode || userCompanyCode || "*"; + // 클라이언트 입력(request.companyCode) 무시 - 인증된 사용자의 회사 코드만 사용 + const companyCode = userCompanyCode || "*"; console.log("🔥 flowDefinitionService.create called with:", { name: request.name, @@ -118,10 +119,21 @@ export class FlowDefinitionService { /** * 플로우 정의 단일 조회 + * companyCode가 전달되면 해당 회사 소유 플로우만 반환 */ - async findById(id: number): Promise { - const query = "SELECT * FROM flow_definition WHERE id = $1"; - const result = await db.query(query, [id]); + async findById(id: number, companyCode?: string): Promise { + let query: string; + let params: any[]; + + if (companyCode && companyCode !== "*") { + query = "SELECT * FROM flow_definition WHERE id = $1 AND company_code = $2"; + params = [id, companyCode]; + } else { + query = "SELECT * FROM flow_definition WHERE id = $1"; + params = [id]; + } + + const result = await db.query(query, params); if (result.length === 0) { return null; @@ -132,10 +144,12 @@ export class FlowDefinitionService { /** * 플로우 정의 수정 + * companyCode가 전달되면 해당 회사 소유 플로우만 수정 가능 */ async update( id: number, - request: UpdateFlowDefinitionRequest + request: UpdateFlowDefinitionRequest, + companyCode?: string ): Promise { const fields: string[] = []; const params: any[] = []; @@ -160,18 +174,27 @@ export class FlowDefinitionService { } if (fields.length === 0) { - return this.findById(id); + return this.findById(id, companyCode); } fields.push(`updated_at = NOW()`); + let whereClause = `WHERE id = $${paramIndex}`; + params.push(id); + paramIndex++; + + if (companyCode && companyCode !== "*") { + whereClause += ` AND company_code = $${paramIndex}`; + params.push(companyCode); + paramIndex++; + } + const query = ` UPDATE flow_definition SET ${fields.join(", ")} - WHERE id = $${paramIndex} + ${whereClause} RETURNING * `; - params.push(id); const result = await db.query(query, params); @@ -184,10 +207,21 @@ export class FlowDefinitionService { /** * 플로우 정의 삭제 + * companyCode가 전달되면 해당 회사 소유 플로우만 삭제 가능 */ - async delete(id: number): Promise { - const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; - const result = await db.query(query, [id]); + async delete(id: number, companyCode?: string): Promise { + let query: string; + let params: any[]; + + if (companyCode && companyCode !== "*") { + query = "DELETE FROM flow_definition WHERE id = $1 AND company_code = $2 RETURNING id"; + params = [id, companyCode]; + } else { + query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id"; + params = [id]; + } + + const result = await db.query(query, params); return result.length > 0; } diff --git a/backend-node/src/services/flowExecutionService.ts b/backend-node/src/services/flowExecutionService.ts index 7a6825f0..54a668e6 100644 --- a/backend-node/src/services/flowExecutionService.ts +++ b/backend-node/src/services/flowExecutionService.ts @@ -11,6 +11,7 @@ import { FlowStepService } from "./flowStepService"; import { FlowConditionParser } from "./flowConditionParser"; import { executeExternalQuery } from "./externalDbHelper"; import { getPlaceholder, buildPaginationClause } from "./dbQueryBuilder"; +import { FlowConditionParser } from "./flowConditionParser"; export class FlowExecutionService { private flowDefinitionService: FlowDefinitionService; @@ -42,7 +43,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -96,7 +97,7 @@ export class FlowExecutionService { } // 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용 - const tableName = step.tableName || flowDef.tableName; + const tableName = FlowConditionParser.sanitizeTableName(step.tableName || flowDef.tableName); // 4. 조건 JSON을 SQL WHERE절로 변환 const { where, params } = FlowConditionParser.toSqlWhere( @@ -267,11 +268,12 @@ export class FlowExecutionService { throw new Error(`Flow step not found: ${stepId}`); } - // 3. 테이블명 결정 - const tableName = step.tableName || flowDef.tableName; - if (!tableName) { + // 3. 테이블명 결정 (SQL 인젝션 방지) + const rawTableName = step.tableName || flowDef.tableName; + if (!rawTableName) { throw new Error("Table name not found"); } + const tableName = FlowConditionParser.sanitizeTableName(rawTableName); // 4. Primary Key 컬럼 결정 (기본값: id) const primaryKeyColumn = flowDef.primaryKey || "id"; @@ -280,8 +282,10 @@ export class FlowExecutionService { `🔍 [updateStepData] Updating table: ${tableName}, PK: ${primaryKeyColumn}=${recordId}` ); - // 5. SET 절 생성 - const updateColumns = Object.keys(updateData); + // 5. SET 절 생성 (컬럼명 SQL 인젝션 방지) + const updateColumns = Object.keys(updateData).map((col) => + FlowConditionParser.sanitizeColumnName(col) + ); if (updateColumns.length === 0) { throw new Error("No columns to update"); } diff --git a/backend-node/src/services/flowProcedureService.ts b/backend-node/src/services/flowProcedureService.ts new file mode 100644 index 00000000..f1b9b66a --- /dev/null +++ b/backend-node/src/services/flowProcedureService.ts @@ -0,0 +1,429 @@ +/** + * 플로우 프로시저 호출 서비스 + * 내부/외부 DB의 프로시저/함수 목록 조회, 파라미터 조회, 실행을 담당 + */ + +import db from "../database/db"; +import { + getExternalPool, + executeExternalQuery, +} from "./externalDbHelper"; +import { getPlaceholder } from "./dbQueryBuilder"; +import { + FlowProcedureConfig, + FlowProcedureParam, + ProcedureListItem, + ProcedureParameterInfo, +} from "../types/flow"; + +export class FlowProcedureService { + /** + * 프로시저/함수 목록 조회 + * information_schema.routines에서 사용 가능한 프로시저/함수를 가져온다 + */ + async listProcedures( + dbSource: "internal" | "external", + connectionId?: number, + schema?: string + ): Promise { + if (dbSource === "external" && connectionId) { + return this.listExternalProcedures(connectionId, schema); + } + return this.listInternalProcedures(schema); + } + + private async listInternalProcedures( + schema?: string + ): Promise { + const targetSchema = schema || "public"; + // 트리거 함수(data_type='trigger')는 직접 호출 대상이 아니므로 제외 + const query = ` + SELECT + routine_name AS name, + routine_schema AS schema, + routine_type AS type, + data_type AS return_type + FROM information_schema.routines + WHERE routine_schema = $1 + AND routine_type IN ('PROCEDURE', 'FUNCTION') + AND data_type != 'trigger' + ORDER BY routine_type, routine_name + `; + const rows = await db.query(query, [targetSchema]); + return rows.map((r: any) => ({ + name: r.name, + schema: r.schema, + type: r.type as "PROCEDURE" | "FUNCTION", + returnType: r.return_type || undefined, + })); + } + + private async listExternalProcedures( + connectionId: number, + schema?: string + ): Promise { + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + + let query: string; + let params: any[]; + + switch (dbType) { + case "postgresql": { + const targetSchema = schema || "public"; + query = ` + SELECT + routine_name AS name, + routine_schema AS schema, + routine_type AS type, + data_type AS return_type + FROM information_schema.routines + WHERE routine_schema = $1 + AND routine_type IN ('PROCEDURE', 'FUNCTION') + AND data_type != 'trigger' + ORDER BY routine_type, routine_name + `; + params = [targetSchema]; + break; + } + case "mysql": + case "mariadb": { + query = ` + SELECT + ROUTINE_NAME AS name, + ROUTINE_SCHEMA AS \`schema\`, + ROUTINE_TYPE AS type, + DATA_TYPE AS return_type + FROM information_schema.ROUTINES + WHERE ROUTINE_SCHEMA = DATABASE() + AND ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + `; + params = []; + break; + } + case "mssql": { + query = ` + SELECT + ROUTINE_NAME AS name, + ROUTINE_SCHEMA AS [schema], + ROUTINE_TYPE AS type, + DATA_TYPE AS return_type + FROM INFORMATION_SCHEMA.ROUTINES + WHERE ROUTINE_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY ROUTINE_TYPE, ROUTINE_NAME + `; + params = []; + break; + } + default: + throw new Error(`프로시저 목록 조회 미지원 DB: ${dbType}`); + } + + const result = await executeExternalQuery(connectionId, query, params); + return (result.rows || []).map((r: any) => ({ + name: r.name || r.NAME, + schema: r.schema || r.SCHEMA || "", + type: (r.type || r.TYPE || "FUNCTION").toUpperCase() as "PROCEDURE" | "FUNCTION", + returnType: r.return_type || r.RETURN_TYPE || undefined, + })); + } + + /** + * 프로시저/함수 파라미터 정보 조회 + */ + async getProcedureParameters( + procedureName: string, + dbSource: "internal" | "external", + connectionId?: number, + schema?: string + ): Promise { + if (dbSource === "external" && connectionId) { + return this.getExternalProcedureParameters( + connectionId, + procedureName, + schema + ); + } + return this.getInternalProcedureParameters(procedureName, schema); + } + + private async getInternalProcedureParameters( + procedureName: string, + schema?: string + ): Promise { + const targetSchema = schema || "public"; + // PostgreSQL의 specific_name은 routine_name + OID 형태이므로 서브쿼리로 매칭 + const query = ` + SELECT + p.parameter_name AS name, + p.ordinal_position AS position, + p.data_type, + p.parameter_mode AS mode, + p.parameter_default AS default_value + FROM information_schema.parameters p + WHERE p.specific_schema = $1 + AND p.specific_name IN ( + SELECT r.specific_name FROM information_schema.routines r + WHERE r.routine_schema = $1 AND r.routine_name = $2 + LIMIT 1 + ) + AND p.parameter_name IS NOT NULL + ORDER BY p.ordinal_position + `; + const rows = await db.query(query, [targetSchema, procedureName]); + return rows.map((r: any) => ({ + name: r.name, + position: parseInt(r.position, 10), + dataType: r.data_type, + mode: this.normalizeParamMode(r.mode), + defaultValue: r.default_value || undefined, + })); + } + + private async getExternalProcedureParameters( + connectionId: number, + procedureName: string, + schema?: string + ): Promise { + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + + let query: string; + let params: any[]; + + switch (dbType) { + case "postgresql": { + const targetSchema = schema || "public"; + query = ` + SELECT + p.parameter_name AS name, + p.ordinal_position AS position, + p.data_type, + p.parameter_mode AS mode, + p.parameter_default AS default_value + FROM information_schema.parameters p + WHERE p.specific_schema = $1 + AND p.specific_name IN ( + SELECT r.specific_name FROM information_schema.routines r + WHERE r.routine_schema = $1 AND r.routine_name = $2 + LIMIT 1 + ) + AND p.parameter_name IS NOT NULL + ORDER BY p.ordinal_position + `; + params = [targetSchema, procedureName]; + break; + } + case "mysql": + case "mariadb": { + query = ` + SELECT + PARAMETER_NAME AS name, + ORDINAL_POSITION AS position, + DATA_TYPE AS data_type, + PARAMETER_MODE AS mode, + '' AS default_value + FROM information_schema.PARAMETERS + WHERE SPECIFIC_SCHEMA = DATABASE() + AND SPECIFIC_NAME = ? + AND PARAMETER_NAME IS NOT NULL + ORDER BY ORDINAL_POSITION + `; + params = [procedureName]; + break; + } + case "mssql": { + query = ` + SELECT + PARAMETER_NAME AS name, + ORDINAL_POSITION AS position, + DATA_TYPE AS data_type, + PARAMETER_MODE AS mode, + '' AS default_value + FROM INFORMATION_SCHEMA.PARAMETERS + WHERE SPECIFIC_NAME = @p1 + AND PARAMETER_NAME IS NOT NULL + ORDER BY ORDINAL_POSITION + `; + params = [procedureName]; + break; + } + default: + throw new Error(`파라미터 조회 미지원 DB: ${dbType}`); + } + + const result = await executeExternalQuery(connectionId, query, params); + return (result.rows || []).map((r: any) => ({ + name: (r.name || r.NAME || "").replace(/^@/, ""), + position: parseInt(r.position || r.POSITION || "0", 10), + dataType: r.data_type || r.DATA_TYPE || "unknown", + mode: this.normalizeParamMode(r.mode || r.MODE), + defaultValue: r.default_value || r.DEFAULT_VALUE || undefined, + })); + } + + /** + * 프로시저/함수 실행 + * 내부 DB는 기존 트랜잭션 client를 사용, 외부 DB는 별도 연결 + */ + async executeProcedure( + config: FlowProcedureConfig, + recordData: Record, + client?: any + ): Promise<{ success: boolean; result?: any; error?: string }> { + const paramValues = this.resolveParameters(config.parameters, recordData); + + if (config.dbSource === "internal") { + return this.executeInternalProcedure(config, paramValues, client); + } + + if (!config.connectionId) { + throw new Error("외부 DB 프로시저 호출에 connectionId가 필요합니다"); + } + return this.executeExternalProcedure(config, paramValues); + } + + /** + * 내부 DB 프로시저 실행 (트랜잭션 client 공유) + */ + private async executeInternalProcedure( + config: FlowProcedureConfig, + paramValues: any[], + client?: any + ): Promise<{ success: boolean; result?: any; error?: string }> { + const schema = config.procedureSchema || "public"; + const safeName = this.sanitizeName(config.procedureName); + const safeSchema = this.sanitizeName(schema); + const qualifiedName = `${safeSchema}.${safeName}`; + + const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", "); + + let sql: string; + if (config.callType === "function") { + // SELECT * FROM fn()을 사용하여 OUT 파라미터를 개별 컬럼으로 반환 + sql = `SELECT * FROM ${qualifiedName}(${placeholders})`; + } else { + sql = `CALL ${qualifiedName}(${placeholders})`; + } + + try { + const executor = client || db; + const result = client + ? await client.query(sql, paramValues) + : await db.query(sql, paramValues); + + const rows = client ? result.rows : result; + return { success: true, result: rows }; + } catch (error: any) { + throw new Error( + `프로시저 실행 실패 [${qualifiedName}]: ${error.message}` + ); + } + } + + /** + * 외부 DB 프로시저 실행 + */ + private async executeExternalProcedure( + config: FlowProcedureConfig, + paramValues: any[] + ): Promise<{ success: boolean; result?: any; error?: string }> { + const connectionId = config.connectionId!; + const poolInfo = await getExternalPool(connectionId); + const dbType = poolInfo.dbType.toLowerCase(); + const safeName = this.sanitizeName(config.procedureName); + const safeSchema = config.procedureSchema + ? this.sanitizeName(config.procedureSchema) + : null; + + let sql: string; + + switch (dbType) { + case "postgresql": { + const qualifiedName = safeSchema + ? `${safeSchema}.${safeName}` + : safeName; + const placeholders = paramValues.map((_, i) => `$${i + 1}`).join(", "); + sql = + config.callType === "function" + ? `SELECT * FROM ${qualifiedName}(${placeholders})` + : `CALL ${qualifiedName}(${placeholders})`; + break; + } + case "mysql": + case "mariadb": { + const placeholders = paramValues.map(() => "?").join(", "); + sql = `CALL ${safeName}(${placeholders})`; + break; + } + case "mssql": { + const paramList = paramValues + .map((_, i) => `@p${i + 1}`) + .join(", "); + sql = `EXEC ${safeName} ${paramList}`; + break; + } + default: + throw new Error(`프로시저 실행 미지원 DB: ${dbType}`); + } + + try { + const result = await executeExternalQuery(connectionId, sql, paramValues); + return { success: true, result: result.rows }; + } catch (error: any) { + throw new Error( + `외부 프로시저 실행 실패 [${safeName}]: ${error.message}` + ); + } + } + + /** + * 설정된 파라미터 매핑에서 실제 값을 추출 + */ + private resolveParameters( + params: FlowProcedureParam[], + recordData: Record + ): any[] { + const inParams = params.filter((p) => p.mode === "IN" || p.mode === "INOUT"); + return inParams.map((param) => { + switch (param.source) { + case "record_field": + if (!param.field) { + throw new Error(`파라미터 ${param.name}: 레코드 필드가 지정되지 않았습니다`); + } + return recordData[param.field] ?? null; + + case "static": + return param.value ?? null; + + case "step_variable": + return recordData[param.field || param.name] ?? param.value ?? null; + + default: + return null; + } + }); + } + + /** + * 이름(스키마/프로시저) SQL Injection 방지용 검증 + */ + private sanitizeName(name: string): string { + if (!/^[a-zA-Z0-9_]+$/.test(name)) { + throw new Error(`유효하지 않은 이름: ${name}`); + } + return name; + } + + /** + * 파라미터 모드 정규화 + */ + private normalizeParamMode(mode: string | null): "IN" | "OUT" | "INOUT" { + if (!mode) return "IN"; + const upper = mode.toUpperCase(); + if (upper === "OUT") return "OUT"; + if (upper === "INOUT") return "INOUT"; + return "IN"; + } +} diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index a5abe410..6f0848e2 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -11,6 +11,7 @@ import { query, queryOne, transaction } from "../database/db"; import { logger } from "../utils/logger"; import axios from "axios"; +import { FlowProcedureService } from "./flowProcedureService"; // ===== 타입 정의 ===== @@ -36,6 +37,7 @@ export type NodeType = | "emailAction" // 이메일 발송 액션 | "scriptAction" // 스크립트 실행 액션 | "httpRequestAction" // HTTP 요청 액션 + | "procedureCallAction" // 프로시저/함수 호출 액션 | "comment" | "log"; @@ -663,6 +665,9 @@ export class NodeFlowExecutionService { case "httpRequestAction": return this.executeHttpRequestAction(node, inputData, context); + case "procedureCallAction": + return this.executeProcedureCallAction(node, inputData, context, client); + case "comment": case "log": // 로그/코멘트는 실행 없이 통과 @@ -4856,4 +4861,105 @@ export class NodeFlowExecutionService { ); } } + + /** + * 프로시저/함수 호출 액션 노드 실행 + */ + private static async executeProcedureCallAction( + node: FlowNode, + inputData: any, + context: ExecutionContext, + client?: any + ): Promise { + const { + dbSource = "internal", + connectionId, + procedureName, + procedureSchema = "public", + callType = "function", + parameters = [], + } = node.data; + + logger.info( + `🔧 프로시저 호출 노드 실행: ${node.data.displayName || node.id}` + ); + logger.info( + ` 프로시저: ${procedureSchema}.${procedureName} (${callType}), DB: ${dbSource}` + ); + + if (!procedureName) { + throw new Error("프로시저/함수가 선택되지 않았습니다."); + } + + const dataArray = Array.isArray(inputData) + ? inputData + : inputData + ? [inputData] + : [{}]; + + const procedureService = new FlowProcedureService(); + const results: any[] = []; + + const config = { + type: "procedure" as const, + dbSource: dbSource as "internal" | "external", + connectionId, + procedureName, + procedureSchema, + callType: callType as "procedure" | "function", + parameters: parameters.map((p: any) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode || "IN", + source: p.source || "static", + field: p.field, + value: p.value, + })), + }; + + for (const record of dataArray) { + try { + logger.info(` 입력 레코드 키: ${Object.keys(record).join(", ")}`); + + const execResult = await procedureService.executeProcedure( + config, + record, + dbSource === "internal" ? client : undefined + ); + + logger.info(` ✅ 프로시저 실행 성공: ${procedureName}`); + + // 프로시저 반환값을 레코드에 평탄화하여 다음 노드에서 필드로 참조 가능하게 함 + let flatResult: Record = {}; + if (Array.isArray(execResult.result) && execResult.result.length > 0) { + const row = execResult.result[0]; + for (const [key, val] of Object.entries(row)) { + // 함수명과 동일한 키(SELECT fn() 결과)는 _procedureReturn으로 매핑 + if (key === procedureName) { + flatResult["_procedureReturn"] = val; + } else { + flatResult[key] = val; + } + } + logger.info(` 반환 필드: ${Object.keys(flatResult).join(", ")}`); + } + + results.push({ + ...record, + ...flatResult, + _procedureResult: execResult.result, + _procedureSuccess: true, + }); + } catch (error: any) { + logger.error(` ❌ 프로시저 실행 실패: ${error.message}`); + throw error; + } + } + + logger.info( + `🔧 프로시저 호출 완료: ${results.length}건 처리` + ); + + return results; + } } diff --git a/backend-node/src/types/flow.ts b/backend-node/src/types/flow.ts index 9f105a49..179eb26f 100644 --- a/backend-node/src/types/flow.ts +++ b/backend-node/src/types/flow.ts @@ -260,6 +260,7 @@ export interface FlowStepDataList { // 데이터 이동 요청 export interface MoveDataRequest { flowId: number; + fromStepId: number; recordId: string; toStepId: number; note?: string; @@ -277,6 +278,7 @@ export interface SqlWhereResult { export type FlowIntegrationType = | "internal" // 내부 DB (기본값) | "external_db" // 외부 DB + | "procedure" // 프로시저/함수 호출 | "rest_api" // REST API (추후 구현) | "webhook" // Webhook (추후 구현) | "hybrid"; // 복합 연동 (추후 구현) @@ -340,8 +342,48 @@ export interface FlowExternalDbIntegrationConfig { customQuery?: string; // operation이 'custom'인 경우 사용 } +// 프로시저 호출 파라미터 정의 +export interface FlowProcedureParam { + name: string; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + source: "record_field" | "static" | "step_variable"; + field?: string; // source가 record_field인 경우: 레코드 컬럼명 + value?: string; // source가 static인 경우: 고정값 +} + +// 프로시저 호출 설정 (integration_config JSON) +export interface FlowProcedureConfig { + type: "procedure"; + dbSource: "internal" | "external"; + connectionId?: number; // 외부 DB인 경우 external_db_connections.id + procedureName: string; + procedureSchema?: string; // 스키마명 (기본: public) + callType: "procedure" | "function"; // CALL vs SELECT + parameters: FlowProcedureParam[]; +} + +// 프로시저/함수 목록 항목 +export interface ProcedureListItem { + name: string; + schema: string; + type: "PROCEDURE" | "FUNCTION"; + returnType?: string; +} + +// 프로시저 파라미터 정보 +export interface ProcedureParameterInfo { + name: string; + position: number; + dataType: string; + mode: "IN" | "OUT" | "INOUT"; + defaultValue?: string; +} + // 연동 설정 통합 타입 -export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가 +export type FlowIntegrationConfig = + | FlowExternalDbIntegrationConfig + | FlowProcedureConfig; // 연동 실행 컨텍스트 export interface FlowIntegrationContext { diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx index 948cb669..1647523e 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/create/page.tsx @@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge"; import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; import { BatchAPI, @@ -133,7 +134,7 @@ export default function BatchCreatePage() { setFromColumns(Array.isArray(columns) ? columns : []); } catch (error) { console.error("FROM 컬럼 목록 로드 실패:", error); - toast.error("컬럼 목록을 불러오는데 실패했습니다."); + showErrorToast("컬럼 목록을 불러오는 데 실패했습니다", error, { guidance: "테이블 정보를 확인해 주세요." }); } }; @@ -242,7 +243,7 @@ export default function BatchCreatePage() { router.push("/admin/batchmng"); } catch (error) { console.error("배치 설정 저장 실패:", error); - toast.error("배치 설정 저장에 실패했습니다."); + showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx index 46aedf1f..a384b645 100644 --- a/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx @@ -10,6 +10,7 @@ import { Database } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useRouter } from "next/navigation"; import { BatchAPI, @@ -75,7 +76,9 @@ export default function BatchManagementPage() { } } catch (error) { console.error("배치 실행 실패:", error); - toast.error("배치 실행 중 오류가 발생했습니다."); + showErrorToast("배치 실행에 실패했습니다", error, { + guidance: "배치 설정을 확인하고 다시 시도해 주세요.", + }); } finally { setExecutingBatch(null); } diff --git a/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx b/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx index 7e433ec7..f98a98d1 100644 --- a/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/exCallConfList/page.tsx @@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge"; import { Plus, Search, Edit, Trash2, TestTube, Filter } from "lucide-react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { ExternalCallConfigAPI, ExternalCallConfig, @@ -57,11 +58,15 @@ export default function ExternalCallConfigsPage() { if (response.success) { setConfigs(response.data || []); } else { - toast.error(response.message || "외부 호출 설정 조회 실패"); + showErrorToast("외부 호출 설정 조회에 실패했습니다", response.message, { + guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.", + }); } } catch (error) { console.error("외부 호출 설정 조회 오류:", error); - toast.error("외부 호출 설정 조회 중 오류가 발생했습니다."); + showErrorToast("외부 호출 설정 조회에 실패했습니다", error, { + guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.", + }); } finally { setLoading(false); } @@ -113,11 +118,15 @@ export default function ExternalCallConfigsPage() { toast.success("외부 호출 설정이 삭제되었습니다."); fetchConfigs(); } else { - toast.error(response.message || "외부 호출 설정 삭제 실패"); + showErrorToast("외부 호출 설정 삭제에 실패했습니다", response.message, { + guidance: "잠시 후 다시 시도해 주세요.", + }); } } catch (error) { console.error("외부 호출 설정 삭제 오류:", error); - toast.error("외부 호출 설정 삭제 중 오류가 발생했습니다."); + showErrorToast("외부 호출 설정 삭제에 실패했습니다", error, { + guidance: "잠시 후 다시 시도해 주세요.", + }); } finally { setDeleteDialogOpen(false); setConfigToDelete(null); @@ -138,7 +147,9 @@ export default function ExternalCallConfigsPage() { } } catch (error) { console.error("외부 호출 설정 테스트 오류:", error); - toast.error("외부 호출 설정 테스트 중 오류가 발생했습니다."); + showErrorToast("외부 호출 테스트 실행에 실패했습니다", error, { + guidance: "URL과 설정을 확인해 주세요.", + }); } }; diff --git a/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx index b8d14e19..c492a526 100644 --- a/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx +++ b/frontend/app/(main)/admin/automaticMng/flowMgmtList/[id]/page.tsx @@ -130,6 +130,8 @@ export default function FlowEditorPage() { tableName: step.tableName, count: stepCounts[step.id] || 0, condition: step.conditionJson, + integrationType: (step as any).integrationType, + procedureName: (step as any).integrationConfig?.procedureName, }, })); diff --git a/frontend/app/(main)/admin/batch-management-new/page.tsx b/frontend/app/(main)/admin/batch-management-new/page.tsx index 3093ed10..6b282ed4 100644 --- a/frontend/app/(main)/admin/batch-management-new/page.tsx +++ b/frontend/app/(main)/admin/batch-management-new/page.tsx @@ -10,6 +10,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Trash2, Plus, ArrowLeft, Save, RefreshCw, Globe, Database, Eye } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { BatchManagementAPI } from "@/lib/api/batchManagement"; // 타입 정의 @@ -469,7 +470,9 @@ export default function BatchManagementNewPage() { } } catch (error) { console.error("배치 저장 오류:", error); - toast.error("배치 저장 중 오류가 발생했습니다."); + showErrorToast("배치 설정 저장에 실패했습니다", error, { + guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", + }); } return; } else if (batchType === "db-to-restapi") { @@ -558,7 +561,9 @@ export default function BatchManagementNewPage() { } } catch (error) { console.error("배치 저장 오류:", error); - toast.error("배치 저장 중 오류가 발생했습니다."); + showErrorToast("배치 설정 저장에 실패했습니다", error, { + guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", + }); } return; } diff --git a/frontend/app/(main)/admin/batch-management/page.tsx b/frontend/app/(main)/admin/batch-management/page.tsx index 5bb82a84..b9ed4230 100644 --- a/frontend/app/(main)/admin/batch-management/page.tsx +++ b/frontend/app/(main)/admin/batch-management/page.tsx @@ -75,7 +75,7 @@ export default function BatchManagementPage() { setJobs(data); } catch (error) { console.error("배치 작업 목록 조회 오류:", error); - toast.error("배치 작업 목록을 불러오는데 실패했습니다."); + showErrorToast("배치 작업 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setIsLoading(false); } @@ -150,7 +150,7 @@ export default function BatchManagementPage() { loadJobs(); } catch (error) { console.error("배치 작업 삭제 오류:", error); - toast.error("배치 작업 삭제에 실패했습니다."); + showErrorToast("배치 작업 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; @@ -160,7 +160,7 @@ export default function BatchManagementPage() { toast.success(`"${job.job_name}" 배치 작업을 실행했습니다.`); } catch (error) { console.error("배치 작업 실행 오류:", error); - toast.error("배치 작업 실행에 실패했습니다."); + showErrorToast("배치 작업 실행에 실패했습니다", error, { guidance: "배치 설정을 확인해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx index 79208186..b93e1797 100644 --- a/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx +++ b/frontend/app/(main)/admin/cascading-management/tabs/AutoFillTab.tsx @@ -45,6 +45,7 @@ import { GripVertical, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { cn } from "@/lib/utils"; import { cascadingAutoFillApi, AutoFillGroup, AutoFillMapping } from "@/lib/api/cascadingAutoFill"; import { tableManagementApi } from "@/lib/api/tableManagement"; @@ -97,7 +98,7 @@ export default function AutoFillTab() { } } catch (error) { console.error("그룹 목록 로드 실패:", error); - toast.error("그룹 목록을 불러오는데 실패했습니다."); + showErrorToast("그룹 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setLoading(false); } @@ -269,7 +270,7 @@ export default function AutoFillTab() { toast.error(response.error || "저장에 실패했습니다."); } } catch (error) { - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("자동입력 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/cascading-management/tabs/CascadingRelationsTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/CascadingRelationsTab.tsx index 943d9d84..9cdfc1c1 100644 --- a/frontend/app/(main)/admin/cascading-management/tabs/CascadingRelationsTab.tsx +++ b/frontend/app/(main)/admin/cascading-management/tabs/CascadingRelationsTab.tsx @@ -33,6 +33,7 @@ import { Loader2, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { cn } from "@/lib/utils"; import { cascadingRelationApi, CascadingRelation, CascadingRelationCreateInput } from "@/lib/api/cascadingRelation"; import { tableManagementApi } from "@/lib/api/tableManagement"; @@ -102,7 +103,7 @@ export default function CascadingRelationsTab() { setRelations(response.data); } } catch (error) { - toast.error("연쇄 관계 목록 조회에 실패했습니다."); + showErrorToast("연쇄 관계 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setLoading(false); } @@ -431,7 +432,7 @@ export default function CascadingRelationsTab() { toast.error(response.message || "저장에 실패했습니다."); } } catch (error) { - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("연쇄 관계 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setSaving(false); } @@ -452,7 +453,7 @@ export default function CascadingRelationsTab() { toast.error(response.message || "삭제에 실패했습니다."); } } catch (error) { - toast.error("삭제 중 오류가 발생했습니다."); + showErrorToast("연쇄 관계 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/cascading-management/tabs/ConditionTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/ConditionTab.tsx index d7caeb57..3bc452c9 100644 --- a/frontend/app/(main)/admin/cascading-management/tabs/ConditionTab.tsx +++ b/frontend/app/(main)/admin/cascading-management/tabs/ConditionTab.tsx @@ -43,6 +43,7 @@ import { TableRow, } from "@/components/ui/table"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { cascadingConditionApi, CascadingCondition, @@ -170,7 +171,7 @@ export default function ConditionTab() { toast.error(response.error || "삭제에 실패했습니다."); } } catch (error) { - toast.error("삭제 중 오류가 발생했습니다."); + showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setIsDeleteDialogOpen(false); setDeletingConditionId(null); @@ -206,7 +207,7 @@ export default function ConditionTab() { toast.error(response.error || "저장에 실패했습니다."); } } catch (error) { - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx index d0d77230..2ca9fe9b 100644 --- a/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx +++ b/frontend/app/(main)/admin/cascading-management/tabs/HierarchyColumnTab.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/card"; import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { hierarchyColumnApi, @@ -300,7 +301,7 @@ export default function HierarchyColumnTab() { } } catch (error) { console.error("저장 에러:", error); - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("계층구조 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; @@ -319,7 +320,7 @@ export default function HierarchyColumnTab() { } } catch (error) { console.error("삭제 에러:", error); - toast.error("삭제 중 오류가 발생했습니다."); + showErrorToast("계층구조 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/standards/[webType]/edit/page.tsx b/frontend/app/(main)/admin/standards/[webType]/edit/page.tsx index f6e98a9f..f9475759 100644 --- a/frontend/app/(main)/admin/standards/[webType]/edit/page.tsx +++ b/frontend/app/(main)/admin/standards/[webType]/edit/page.tsx @@ -11,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react"; import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes"; import { AVAILABLE_COMPONENTS, getComponentInfo } from "@/lib/utils/availableComponents"; @@ -148,7 +149,7 @@ export default function EditWebTypePage() { toast.success("웹타입이 성공적으로 수정되었습니다."); router.push(`/admin/standards/${webType}`); } catch (error) { - toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다."); + showErrorToast("웹타입 수정에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/standards/page.tsx b/frontend/app/(main)/admin/standards/page.tsx index 8c5e7e82..71a63371 100644 --- a/frontend/app/(main)/admin/standards/page.tsx +++ b/frontend/app/(main)/admin/standards/page.tsx @@ -19,6 +19,7 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react"; import { useWebTypes } from "@/hooks/admin/useWebTypes"; import Link from "next/link"; @@ -90,7 +91,7 @@ export default function WebTypesManagePage() { await deleteWebType(webType); toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`); } catch (error) { - toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다."); + showErrorToast("웹타입 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx b/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx index 75f00cdb..0b211a79 100644 --- a/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/collection-managementList/page.tsx @@ -37,6 +37,7 @@ import { RefreshCw } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection"; import CollectionConfigModal from "@/components/admin/CollectionConfigModal"; @@ -69,7 +70,7 @@ export default function CollectionManagementPage() { setConfigs(data); } catch (error) { console.error("수집 설정 목록 조회 오류:", error); - toast.error("수집 설정 목록을 불러오는데 실패했습니다."); + showErrorToast("수집 설정 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setIsLoading(false); } @@ -131,7 +132,7 @@ export default function CollectionManagementPage() { toast.success(`"${config.config_name}" 수집 작업을 시작했습니다.`); } catch (error) { console.error("수집 작업 실행 오류:", error); - toast.error("수집 작업 실행에 실패했습니다."); + showErrorToast("수집 작업 실행에 실패했습니다", error, { guidance: "수집 설정을 확인해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx index d55a6cf1..de0b8d95 100644 --- a/frontend/app/(main)/admin/systemMng/dataflow/page.tsx +++ b/frontend/app/(main)/admin/systemMng/dataflow/page.tsx @@ -6,6 +6,7 @@ import DataFlowList from "@/components/dataflow/DataFlowList"; import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor"; import { useAuth } from "@/hooks/useAuth"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Button } from "@/components/ui/button"; import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ArrowLeft } from "lucide-react"; @@ -35,7 +36,7 @@ export default function DataFlowPage() { toast.success("플로우를 불러왔습니다."); } catch (error: any) { console.error("❌ 플로우 불러오기 실패:", error); - toast.error(error.message || "플로우를 불러오는데 실패했습니다."); + showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } }; diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index d5c41e6a..e2911ed8 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -24,6 +24,7 @@ import { import { cn } from "@/lib/utils"; import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useMultiLang } from "@/hooks/useMultiLang"; import { useAuth } from "@/hooks/useAuth"; import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement"; @@ -331,11 +332,15 @@ export default function TableManagementPage() { setTables(response.data.data); toast.success("테이블 목록을 성공적으로 로드했습니다."); } else { - toast.error(response.data.message || "테이블 목록 로드에 실패했습니다."); + showErrorToast("테이블 목록을 불러오는 데 실패했습니다", response.data.message, { + guidance: "네트워크 연결을 확인해 주세요.", + }); } } catch (error) { // console.error("테이블 목록 로드 실패:", error); - toast.error("테이블 목록 로드 중 오류가 발생했습니다."); + showErrorToast("테이블 목록을 불러오는 데 실패했습니다", error, { + guidance: "네트워크 연결을 확인해 주세요.", + }); } finally { setLoading(false); } @@ -403,11 +408,15 @@ export default function TableManagementPage() { setTotalColumns(data.total || processedColumns.length); toast.success("컬럼 정보를 성공적으로 로드했습니다."); } else { - toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다."); + showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", response.data.message, { + guidance: "네트워크 연결을 확인해 주세요.", + }); } } catch (error) { // console.error("컬럼 타입 정보 로드 실패:", error); - toast.error("컬럼 정보 로드 중 오류가 발생했습니다."); + showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", error, { + guidance: "네트워크 연결을 확인해 주세요.", + }); } finally { setColumnsLoading(false); } @@ -777,11 +786,15 @@ export default function TableManagementPage() { loadColumnTypes(selectedTable); }, 1000); } else { - toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다."); + showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, { + guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", + }); } } catch (error) { // console.error("컬럼 설정 저장 실패:", error); - toast.error("컬럼 설정 저장 중 오류가 발생했습니다."); + showErrorToast("컬럼 설정 저장에 실패했습니다", error, { + guidance: "입력 데이터를 확인하고 다시 시도해 주세요.", + }); } }; @@ -980,12 +993,16 @@ export default function TableManagementPage() { loadColumnTypes(selectedTable, 1, pageSize); }, 1000); } else { - toast.error(response.data.message || "설정 저장에 실패했습니다."); + showErrorToast("설정 저장에 실패했습니다", response.data.message, { + guidance: "잠시 후 다시 시도해 주세요.", + }); } } } catch (error) { // console.error("설정 저장 실패:", error); - toast.error("설정 저장 중 오류가 발생했습니다."); + showErrorToast("설정 저장에 실패했습니다", error, { + guidance: "잠시 후 다시 시도해 주세요.", + }); } finally { setIsSaving(false); } @@ -1091,7 +1108,9 @@ export default function TableManagementPage() { toast.error(response.data.message || "PK 설정 실패"); } } catch (error: any) { - toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다."); + showErrorToast("PK 설정에 실패했습니다", error, { + guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.", + }); } finally { setPkDialogOpen(false); } @@ -1115,7 +1134,9 @@ export default function TableManagementPage() { toast.error(response.data.message || "인덱스 설정 실패"); } } catch (error: any) { - toast.error(error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다."); + showErrorToast("인덱스 설정에 실패했습니다", error, { + guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.", + }); } }, [selectedTable, loadConstraints], @@ -1154,10 +1175,14 @@ export default function TableManagementPage() { ), ); } else { - toast.error(response.data.message || "UNIQUE 설정 실패"); + showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", response.data.message, { + guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.", + }); } } catch (error: any) { - toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); + showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", error, { + guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.", + }); } }, [selectedTable], @@ -1188,12 +1213,14 @@ export default function TableManagementPage() { ), ); } else { - toast.error(response.data.message || "NOT NULL 설정 실패"); + showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", response.data.message, { + guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.", + }); } } catch (error: any) { - toast.error( - error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.", - ); + showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", error, { + guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.", + }); } }, [selectedTable], @@ -1225,10 +1252,14 @@ export default function TableManagementPage() { // 테이블 목록 새로고침 await loadTables(); } else { - toast.error(result.message || "테이블 삭제에 실패했습니다."); + showErrorToast("테이블 삭제에 실패했습니다", result.message, { + guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", + }); } } catch (error: any) { - toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다."); + showErrorToast("테이블 삭제에 실패했습니다", error, { + guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", + }); } finally { setIsDeleting(false); setDeleteDialogOpen(false); @@ -1308,7 +1339,9 @@ export default function TableManagementPage() { setSelectedTableIds(new Set()); await loadTables(); } catch (error: any) { - toast.error("테이블 삭제 중 오류가 발생했습니다."); + showErrorToast("테이블 삭제에 실패했습니다", error, { + guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.", + }); } finally { setIsDeleting(false); setDeleteDialogOpen(false); diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index d1e07abe..d65b7884 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -9,6 +9,7 @@ import { ScreenDefinition, LayoutData, ComponentData } from "@/types/screen"; import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { initializeComponents } from "@/lib/registry/components"; import { EditModal } from "@/components/screen/EditModal"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; @@ -219,7 +220,7 @@ function ScreenViewPage() { } catch (error) { console.error("화면 로드 실패:", error); setError("화면을 불러오는데 실패했습니다."); - toast.error("화면을 불러오는데 실패했습니다."); + showErrorToast("화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index 861795b5..224fb48d 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -8,6 +8,7 @@ import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; @@ -135,7 +136,7 @@ function PopScreenViewPage() { } catch (error) { console.error("[POP] 화면 로드 실패:", error); setError("화면을 불러오는데 실패했습니다."); - toast.error("화면을 불러오는데 실패했습니다."); + showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/components/GlobalFileViewer.tsx b/frontend/components/GlobalFileViewer.tsx index 248de1d5..34cf8e60 100644 --- a/frontend/components/GlobalFileViewer.tsx +++ b/frontend/components/GlobalFileViewer.tsx @@ -11,6 +11,7 @@ import { downloadFile } from "@/lib/api/file"; import { FileViewerModal } from "@/lib/registry/components/file-upload/FileViewerModal"; import { formatFileSize } from "@/lib/utils"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { File, FileText, @@ -134,7 +135,7 @@ export const GlobalFileViewer: React.FC = ({ toast.success(`파일 다운로드 시작: ${file.realFileName}`); } catch (error) { console.error("파일 다운로드 오류:", error); - toast.error("파일 다운로드에 실패했습니다."); + showErrorToast("파일 다운로드에 실패했습니다", error, { guidance: "파일이 존재하는지 확인하고 다시 시도해 주세요." }); } }; diff --git a/frontend/components/admin/AdvancedBatchModal.tsx b/frontend/components/admin/AdvancedBatchModal.tsx index 1276bcad..54b86cbf 100644 --- a/frontend/components/admin/AdvancedBatchModal.tsx +++ b/frontend/components/admin/AdvancedBatchModal.tsx @@ -20,6 +20,7 @@ import { import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch"; import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; @@ -123,7 +124,7 @@ export default function AdvancedBatchModal({ setConnections(list); } catch (error) { console.error("연결 목록 조회 오류:", error); - toast.error("연결 목록을 불러오는데 실패했습니다."); + showErrorToast("연결 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } }; @@ -190,7 +191,7 @@ export default function AdvancedBatchModal({ onClose(); } catch (error) { console.error("배치 저장 오류:", error); - toast.error(error instanceof Error ? error.message : "저장에 실패했습니다."); + showErrorToast("배치 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setIsLoading(false); } diff --git a/frontend/components/admin/DDLLogViewer.tsx b/frontend/components/admin/DDLLogViewer.tsx index f707511b..0889f1bc 100644 --- a/frontend/components/admin/DDLLogViewer.tsx +++ b/frontend/components/admin/DDLLogViewer.tsx @@ -33,6 +33,7 @@ import { Trash2, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; import { ddlApi } from "../../lib/api/ddl"; @@ -71,7 +72,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { setStatistics(statsResult); } catch (error) { // console.error("DDL 로그 로드 실패:", error); - toast.error("DDL 로그를 불러오는데 실패했습니다."); + showErrorToast("DDL 로그를 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { if (showLoading) setLoading(false); setRefreshing(false); @@ -108,7 +109,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) { loadData(false); } catch (error) { // console.error("로그 정리 실패:", error); - toast.error("로그 정리에 실패했습니다."); + showErrorToast("로그 정리에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/components/admin/ExternalCallConfigModal.tsx b/frontend/components/admin/ExternalCallConfigModal.tsx index 30694034..e881b17a 100644 --- a/frontend/components/admin/ExternalCallConfigModal.tsx +++ b/frontend/components/admin/ExternalCallConfigModal.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { ExternalCallConfigAPI, ExternalCallConfig, @@ -259,7 +260,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig } } catch (error) { console.error("외부 호출 설정 저장 오류:", error); - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("외부 호출 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/components/admin/LayoutFormModal.tsx b/frontend/components/admin/LayoutFormModal.tsx index a4bcdf4f..ad17198d 100644 --- a/frontend/components/admin/LayoutFormModal.tsx +++ b/frontend/components/admin/LayoutFormModal.tsx @@ -32,6 +32,7 @@ import { } from "lucide-react"; import { LayoutCategory } from "@/types/layout"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; interface LayoutFormModalProps { open: boolean; @@ -210,7 +211,7 @@ export const LayoutFormModal: React.FC = ({ open, onOpenCh success: false, message: result.message || "레이아웃 생성에 실패했습니다.", }); - toast.error("레이아웃 생성 실패"); + showErrorToast("레이아웃 생성에 실패했습니다", result.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } } catch (error) { console.error("레이아웃 생성 오류:", error); @@ -218,7 +219,7 @@ export const LayoutFormModal: React.FC = ({ open, onOpenCh success: false, message: "서버 오류가 발생했습니다.", }); - toast.error("서버 오류"); + showErrorToast("레이아웃 생성에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setIsGenerating(false); } diff --git a/frontend/components/admin/MenuCopyDialog.tsx b/frontend/components/admin/MenuCopyDialog.tsx index c33e726b..cc2a8e45 100644 --- a/frontend/components/admin/MenuCopyDialog.tsx +++ b/frontend/components/admin/MenuCopyDialog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Loader2 } from "lucide-react"; import { Dialog, @@ -94,7 +95,7 @@ export function MenuCopyDialog({ } } catch (error) { console.error("회사 목록 조회 실패:", error); - toast.error("회사 목록을 불러올 수 없습니다"); + showErrorToast("회사 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setLoadingCompanies(false); } @@ -160,7 +161,7 @@ export function MenuCopyDialog({ } } catch (error: any) { console.error("메뉴 복사 오류:", error); - toast.error(error.message || "메뉴 복사 중 오류가 발생했습니다"); + showErrorToast("메뉴 복사에 실패했습니다", error, { guidance: "복사 대상과 설정을 확인해 주세요." }); } finally { setCopying(false); } diff --git a/frontend/components/admin/MenuFormModal.tsx b/frontend/components/admin/MenuFormModal.tsx index 43f17b52..c9940d78 100644 --- a/frontend/components/admin/MenuFormModal.tsx +++ b/frontend/components/admin/MenuFormModal.tsx @@ -20,6 +20,7 @@ import { toast } from "sonner"; import { ChevronDown, Search } from "lucide-react"; import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang"; import { ScreenDefinition } from "@/types/screen"; +import { MenuIconPicker } from "./MenuIconPicker"; interface Company { company_code: string; @@ -77,6 +78,7 @@ export const MenuFormModal: React.FC = ({ status: "ACTIVE", companyCode: parentCompanyCode || "none", langKey: "", + menuIcon: "", }); // 화면 할당 관련 상태 @@ -275,6 +277,7 @@ export const MenuFormModal: React.FC = ({ const status = menu.status || menu.STATUS || "active"; const companyCode = menu.company_code || menu.COMPANY_CODE || ""; const langKey = menu.lang_key || menu.LANG_KEY || ""; + const menuIcon = menu.menu_icon || menu.MENU_ICON || ""; // 메뉴 타입 변환 (admin/user -> 0/1) let convertedMenuType = menuType; @@ -307,7 +310,8 @@ export const MenuFormModal: React.FC = ({ menuType: convertedMenuType, status: convertedStatus, companyCode: companyCode, - langKey: langKey, // 다국어 키 설정 + langKey: langKey, + menuIcon: menuIcon, }); // URL 타입 설정 @@ -420,9 +424,10 @@ export const MenuFormModal: React.FC = ({ menuDesc: "", seq: 1, menuType: defaultMenuType, - status: "ACTIVE", // 기본값은 활성화 - companyCode: parentCompanyCode || "none", // 상위 메뉴의 회사 코드를 기본값으로 설정 - langKey: "", // 다국어 키 초기화 + status: "ACTIVE", + companyCode: parentCompanyCode || "none", + langKey: "", + menuIcon: "", }); // console.log("메뉴 등록 기본값 설정:", { @@ -839,6 +844,11 @@ export const MenuFormModal: React.FC = ({ /> + handleInputChange("menuIcon", iconName)} + /> +
diff --git a/frontend/components/admin/MenuIconPicker.tsx b/frontend/components/admin/MenuIconPicker.tsx new file mode 100644 index 00000000..76919bc8 --- /dev/null +++ b/frontend/components/admin/MenuIconPicker.tsx @@ -0,0 +1,553 @@ +"use client"; + +import React, { useState, useMemo, useRef, useEffect, useCallback } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; +import { Search, X, ChevronDown } from "lucide-react"; +import * as LucideIcons from "lucide-react"; + +type IconComponent = React.FC<{ className?: string }>; + +// lucide-react에서 아이콘 컴포넌트만 필터링 (유틸 함수, 타입 등 제외) +const EXCLUDED_EXPORTS = new Set([ + "createLucideIcon", + "defaultAttributes", + "Icon", + "icons", + "default", +]); + +// PascalCase인지 확인 (아이콘 컴포넌트는 모두 PascalCase) +const isPascalCase = (str: string): boolean => /^[A-Z][a-zA-Z0-9]*$/.test(str); + +// 한글 키워드 매핑 (자주 쓰는 아이콘에 한글 검색어 추가) +const KOREAN_KEYWORDS: Record = { + Home: ["홈", "메인", "대시보드"], + FileText: ["문서", "파일", "텍스트"], + Users: ["사용자", "회원", "인사", "팀"], + User: ["사용자", "회원", "개인"], + Settings: ["설정", "관리", "시스템"], + Shield: ["보안", "권한", "관리자"], + Package: ["제품", "품목", "패키지", "상품"], + BarChart3: ["통계", "차트", "분석", "리포트"], + BarChart2: ["통계", "차트", "분석"], + BarChart: ["통계", "차트"], + Building2: ["회사", "조직", "건물", "부서"], + Building: ["회사", "건물"], + ShoppingCart: ["영업", "판매", "주문", "장바구니"], + ShoppingBag: ["쇼핑", "가방", "구매"], + Truck: ["물류", "배송", "운송", "출하"], + Warehouse: ["창고", "재고", "입고"], + Factory: ["생산", "공장", "제조"], + Wrench: ["설비", "유지보수", "수리", "도구"], + ClipboardCheck: ["품질", "검사", "체크리스트"], + ClipboardList: ["작업지시", "지시서", "할일"], + Clipboard: ["클립보드", "복사"], + DollarSign: ["회계", "금액", "비용", "가격"], + Receipt: ["영수증", "청구", "전표"], + Calendar: ["일정", "캘린더", "날짜"], + CalendarDays: ["일정", "캘린더", "날짜", "일"], + Clock: ["시간", "이력", "히스토리"], + FolderOpen: ["폴더", "분류", "카테고리"], + Folder: ["폴더", "분류", "그룹"], + FolderPlus: ["폴더추가", "분류추가"], + Database: ["데이터", "DB", "저장소"], + Globe: ["글로벌", "다국어", "웹", "세계"], + Mail: ["메일", "이메일"], + Bell: ["알림", "벨", "통지"], + BellRing: ["알림", "벨", "울림"], + Search: ["검색", "조회", "찾기"], + ListOrdered: ["목록", "리스트", "순서"], + List: ["목록", "리스트"], + LayoutGrid: ["그리드", "레이아웃", "화면"], + LayoutDashboard: ["대시보드", "레이아웃"], + Tag: ["태그", "라벨", "분류"], + Tags: ["태그", "라벨", "분류", "복수"], + BookOpen: ["문서", "매뉴얼", "가이드"], + Book: ["책", "문서"], + Boxes: ["BOM", "자재", "부품", "구성"], + Box: ["박스", "상자", "제품"], + GitBranch: ["흐름", "분기", "프로세스"], + Workflow: ["워크플로우", "플로우", "프로세스"], + ArrowRightLeft: ["이동", "전환", "교환"], + ArrowRight: ["오른쪽", "다음", "진행"], + ArrowLeft: ["왼쪽", "이전", "뒤로"], + ArrowUp: ["위", "상승", "업"], + ArrowDown: ["아래", "하강", "다운"], + Layers: ["레이어", "계층", "구조"], + PieChart: ["파이차트", "통계", "비율"], + TrendingUp: ["추세", "성장", "상승"], + TrendingDown: ["추세", "하락", "하강"], + AlertTriangle: ["경고", "주의"], + AlertCircle: ["경고", "주의", "원"], + CheckCircle: ["완료", "승인", "확인"], + CheckCircle2: ["완료", "승인", "확인"], + Check: ["확인", "체크"], + Cog: ["톱니바퀴", "설정", "옵션"], + Map: ["지도", "위치", "경로"], + MapPin: ["지도핀", "위치", "장소"], + Printer: ["프린터", "인쇄", "출력"], + UserCog: ["사용자설정", "계정", "프로필"], + UserPlus: ["사용자추가", "회원가입"], + UserCheck: ["사용자확인", "인증"], + Key: ["키", "권한", "인증", "보안"], + Lock: ["잠금", "보안", "비밀번호"], + LockOpen: ["잠금해제", "열기"], + Unlock: ["잠금해제"], + Hammer: ["작업", "공구", "수리"], + Ruler: ["측정", "규격", "사양"], + Scan: ["스캔", "바코드", "QR"], + QrCode: ["QR코드", "큐알"], + ScrollText: ["계약", "문서", "스크롤"], + HandCoins: ["구매", "발주", "거래"], + CircleDollarSign: ["매출", "수익", "원가"], + FileSpreadsheet: ["엑셀", "스프레드시트", "표"], + FilePlus2: ["신규", "추가", "등록"], + FilePlus: ["파일추가", "신규"], + FileCheck2: ["승인", "결재", "확인"], + FileCheck: ["파일확인"], + Zap: ["전기", "에너지", "빠른"], + Gauge: ["게이지", "성능", "속도"], + HardDrive: ["저장", "서버", "디스크"], + Monitor: ["모니터", "화면", "디스플레이"], + Smartphone: ["모바일", "스마트폰", "앱"], + Lightbulb: ["아이디어", "제안", "개선"], + Star: ["별", "즐겨찾기", "중요"], + Heart: ["좋아요", "관심", "찜"], + Bookmark: ["북마크", "저장", "즐겨찾기"], + Flag: ["플래그", "깃발", "표시"], + Award: ["수상", "인증", "포상"], + Trophy: ["트로피", "우승", "성과"], + Target: ["목표", "타겟", "대상"], + Crosshair: ["크로스헤어", "조준", "정확"], + Eye: ["보기", "조회", "미리보기"], + EyeOff: ["숨기기", "비공개"], + Image: ["이미지", "사진", "그림"], + Camera: ["카메라", "사진", "촬영"], + Video: ["비디오", "영상", "동영상"], + Music: ["음악", "오디오", "사운드"], + Mic: ["마이크", "음성", "녹음"], + Phone: ["전화", "연락", "콜"], + PhoneCall: ["통화", "전화"], + MessageSquare: ["메시지", "채팅", "대화"], + MessageCircle: ["메시지", "채팅"], + Send: ["보내기", "전송", "발송"], + Share2: ["공유", "전달"], + Link: ["링크", "연결", "URL"], + ExternalLink: ["외부링크", "새창"], + Download: ["다운로드", "내려받기"], + Upload: ["업로드", "올리기"], + CloudUpload: ["클라우드업로드", "올리기"], + CloudDownload: ["클라우드다운로드", "내려받기"], + Cloud: ["클라우드", "구름"], + Server: ["서버", "시스템"], + Cpu: ["CPU", "프로세서", "처리"], + Wifi: ["와이파이", "네트워크", "무선"], + Activity: ["활동", "모니터링", "심박"], + Thermometer: ["온도", "온도계", "측정"], + Droplets: ["물", "수질", "액체"], + Wind: ["바람", "공기", "환기"], + Sun: ["태양", "밝기", "낮"], + Moon: ["달", "야간", "다크모드"], + Umbrella: ["우산", "보호", "보험"], + Compass: ["나침반", "방향", "가이드"], + Navigation: ["네비게이션", "안내"], + RotateCcw: ["되돌리기", "새로고침", "초기화"], + RefreshCw: ["새로고침", "갱신", "동기화"], + Repeat: ["반복", "되풀이"], + Shuffle: ["셔플", "무작위", "랜덤"], + Filter: ["필터", "거르기", "조건"], + SlidersHorizontal: ["슬라이더", "조정", "필터"], + Maximize2: ["최대화", "전체화면"], + Minimize2: ["최소화", "축소"], + Move: ["이동", "옮기기"], + Copy: ["복사", "복제"], + Scissors: ["가위", "잘라내기"], + Trash2: ["삭제", "쓰레기통", "휴지통"], + Trash: ["삭제", "쓰레기"], + Archive: ["보관", "아카이브", "저장"], + ArchiveRestore: ["복원", "복구"], + Plus: ["추가", "더하기", "플러스"], + Minus: ["빼기", "마이너스", "제거"], + PlusCircle: ["추가", "원형추가"], + MinusCircle: ["제거", "원형제거"], + XCircle: ["닫기", "취소", "제거"], + Info: ["정보", "안내", "도움말"], + HelpCircle: ["도움말", "질문", "안내"], + CircleAlert: ["경고", "주의", "원형경고"], + Ban: ["금지", "차단", "비허용"], + ShieldCheck: ["보안확인", "인증완료"], + ShieldAlert: ["보안경고", "위험"], + LogIn: ["로그인", "접속"], + LogOut: ["로그아웃", "종료"], + Power: ["전원", "켜기/끄기"], + ToggleLeft: ["토글", "스위치", "끄기"], + ToggleRight: ["토글", "스위치", "켜기"], + Percent: ["퍼센트", "비율", "할인"], + Hash: ["해시", "번호", "코드"], + AtSign: ["앳", "이메일", "골뱅이"], + Code: ["코드", "개발", "프로그래밍"], + Terminal: ["터미널", "명령어", "콘솔"], + Table: ["테이블", "표", "데이터"], + Table2: ["테이블", "표"], + Columns: ["컬럼", "열", "항목"], + Rows: ["행", "줄"], + Grid3x3: ["그리드", "격자", "표"], + PanelLeft: ["패널", "사이드바", "왼쪽"], + PanelRight: ["패널", "사이드바", "오른쪽"], + Split: ["분할", "나누기"], + Combine: ["결합", "합치기"], + Network: ["네트워크", "연결망"], + Radio: ["라디오", "옵션"], + CircleDot: ["원형점", "선택"], + SquareCheck: ["체크박스", "선택"], + Square: ["사각형", "상자"], + Circle: ["원", "동그라미"], + Triangle: ["삼각형", "세모"], + Hexagon: ["육각형", "벌집"], + Diamond: ["다이아몬드", "마름모"], + Pen: ["펜", "작성", "편집"], + Pencil: ["연필", "수정", "편집"], + PenLine: ["펜라인", "서명"], + Eraser: ["지우개", "삭제", "초기화"], + Palette: ["팔레트", "색상", "디자인"], + Paintbrush: ["브러시", "페인트", "디자인"], + Figma: ["피그마", "디자인"], + Type: ["타입", "글꼴", "폰트"], + Bold: ["굵게", "볼드"], + Italic: ["기울임", "이탤릭"], + AlignLeft: ["왼쪽정렬"], + AlignCenter: ["가운데정렬"], + AlignRight: ["오른쪽정렬"], + Footprints: ["발자국", "추적", "이력"], + Fingerprint: ["지문", "인증", "보안"], + ScanLine: ["스캔라인", "인식"], + Barcode: ["바코드"], + CreditCard: ["신용카드", "결제", "카드"], + Wallet: ["지갑", "결제", "자금"], + Banknote: ["지폐", "현금", "돈"], + Coins: ["동전", "코인"], + PiggyBank: ["저금통", "저축", "예산"], + Landmark: ["랜드마크", "은행", "기관"], + Store: ["매장", "상점", "가게"], + GraduationCap: ["졸업", "교육", "학습"], + School: ["학교", "교육", "훈련"], + Library: ["도서관", "라이브러리"], + BookMarked: ["북마크", "표시된책"], + Notebook: ["노트북", "공책", "메모"], + NotebookPen: ["노트작성", "메모"], + FileArchive: ["압축파일", "아카이브"], + FileAudio: ["오디오파일", "음악파일"], + FileVideo: ["비디오파일", "영상파일"], + FileImage: ["이미지파일", "사진파일"], + FileCode: ["코드파일", "소스파일"], + FileJson: ["JSON파일", "데이터파일"], + FileCog: ["파일설정", "환경설정"], + FileSearch: ["파일검색", "문서검색"], + FileWarning: ["파일경고", "주의파일"], + FileX: ["파일삭제", "파일제거"], + Files: ["파일들", "다중파일"], + FolderSearch: ["폴더검색"], + FolderCog: ["폴더설정"], + FolderInput: ["입력폴더", "수신"], + FolderOutput: ["출력폴더", "발신"], + FolderSync: ["폴더동기화"], + FolderTree: ["폴더트리", "계층구조"], + Inbox: ["받은편지함", "수신"], + MailOpen: ["메일열기", "읽음"], + MailPlus: ["메일추가", "새메일"], + CalendarCheck: ["일정확인", "예약확인"], + CalendarPlus: ["일정추가", "새일정"], + CalendarX: ["일정취소", "일정삭제"], + Timer: ["타이머", "시간측정"], + Hourglass: ["모래시계", "대기", "로딩"], + AlarmClock: ["알람", "시계"], + Watch: ["시계", "손목시계"], + Rocket: ["로켓", "출시", "배포"], + Plane: ["비행기", "항공", "운송"], + Ship: ["배", "선박", "해운"], + Car: ["자동차", "차량"], + Bus: ["버스", "대중교통"], + Train: ["기차", "열차", "철도"], + Bike: ["자전거", "이동"], + Fuel: ["연료", "주유"], + Construction: ["공사", "건설", "설치"], + HardHat: ["안전모", "건설", "안전"], + Shovel: ["삽", "건설", "시공"], + Drill: ["드릴", "공구"], + Nut: ["너트", "부품", "볼트"], + Plug: ["플러그", "전원", "연결"], + Cable: ["케이블", "선", "연결"], + Battery: ["배터리", "충전"], + BatteryCharging: ["충전중", "배터리"], + Signal: ["신호", "강도"], + Antenna: ["안테나", "수신"], + Bluetooth: ["블루투스", "무선"], + Usb: ["USB", "연결"], + SquareStack: ["스택", "쌓기", "레이어"], + Component: ["컴포넌트", "부품", "구성요소"], + Puzzle: ["퍼즐", "조각", "모듈"], + Blocks: ["블록", "구성요소"], + GitCommit: ["커밋", "변경"], + GitMerge: ["병합", "머지"], + GitPullRequest: ["풀리퀘스트", "요청"], + GitCompare: ["비교", "차이"], + CirclePlay: ["재생", "플레이"], + CirclePause: ["일시정지", "멈춤"], + CircleStop: ["정지", "중지"], + SkipForward: ["다음", "건너뛰기"], + SkipBack: ["이전", "뒤로"], + Volume2: ["볼륨", "소리"], + VolumeX: ["음소거"], + Headphones: ["헤드폰", "오디오"], + Speaker: ["스피커", "소리"], + Projector: ["프로젝터", "발표"], + Presentation: ["프레젠테이션", "발표"], + GanttChart: ["간트차트", "일정관리", "프로젝트"], + KanbanSquare: ["칸반", "보드", "프로젝트"], + ListTodo: ["할일목록", "체크리스트"], + ListChecks: ["체크목록", "확인목록"], + ListFilter: ["필터목록", "조건목록"], + ListTree: ["트리목록", "계층목록"], + StretchHorizontal: ["가로확장"], + StretchVertical: ["세로확장"], + Maximize: ["최대화"], + Minimize: ["최소화"], + Expand: ["확장", "펼치기"], + Shrink: ["축소", "줄이기"], + ZoomIn: ["확대"], + ZoomOut: ["축소"], + Focus: ["포커스", "집중"], + Crosshairs: ["조준", "대상"], + Locate: ["위치찾기", "현재위치"], + LocateFixed: ["위치고정"], + LocateOff: ["위치끄기"], + Spline: ["스플라인", "곡선"], + BrainCircuit: ["AI", "인공지능", "두뇌"], + Brain: ["두뇌", "지능", "생각"], + Bot: ["봇", "로봇", "자동화"], + Sparkles: ["반짝", "AI", "마법"], + Wand2: ["마법봉", "자동", "AI"], + FlaskConical: ["실험", "연구", "시험"], + TestTube: ["시험관", "검사", "테스트"], + Microscope: ["현미경", "분석", "연구"], + Stethoscope: ["청진기", "의료", "진단"], + Syringe: ["주사기", "의료"], + Pill: ["약", "의약품"], + HeartPulse: ["심박", "건강", "의료"], + Dna: ["DNA", "유전", "생명과학"], + Atom: ["원자", "과학", "화학"], + Beaker: ["비커", "실험", "화학"], + Scale: ["저울", "무게", "측정"], + Weight: ["무게", "중량"], + Ratio: ["비율", "비교"], + Calculator: ["계산기", "계산"], + Binary: ["이진수", "코드"], + Regex: ["정규식", "패턴"], + Variable: ["변수", "값"], + FunctionSquare: ["함수", "기능"], + Braces: ["중괄호", "코드"], + Brackets: ["대괄호", "배열"], + Parentheses: ["소괄호", "그룹"], + Tally5: ["집계", "카운트", "합계"], + Sigma: ["시그마", "합계", "총합"], + Infinity: ["무한", "반복"], + Pi: ["파이", "수학"], + Omega: ["오메가", "마지막"], +}; + +interface IconEntry { + name: string; + component: IconComponent; + keywords: string[]; +} + +// 모든 Lucide 아이콘을 동적으로 가져오기 +const ALL_ICONS: IconEntry[] = (() => { + const entries: IconEntry[] = []; + for (const [name, maybeComponent] of Object.entries(LucideIcons)) { + if (EXCLUDED_EXPORTS.has(name)) continue; + if (!isPascalCase(name)) continue; + // lucide-react 아이콘은 forwardRef + memo로 감싸진 React 컴포넌트 (object) + const comp = maybeComponent as any; + const isReactComponent = + typeof comp === "function" || + (typeof comp === "object" && comp !== null && comp.$$typeof); + if (!isReactComponent) continue; + + const koreanKw = KOREAN_KEYWORDS[name] || []; + entries.push({ + name, + component: comp as IconComponent, + keywords: [...koreanKw, name.toLowerCase()], + }); + } + return entries.sort((a, b) => a.name.localeCompare(b.name)); +})(); + +export function getIconComponent(iconName: string | null | undefined): IconComponent | null { + if (!iconName) return null; + const entry = ALL_ICONS.find((e) => e.name === iconName); + return entry?.component || null; +} + +interface MenuIconPickerProps { + value: string; + onChange: (iconName: string) => void; + label?: string; +} + +export const MenuIconPicker: React.FC = ({ + value, + onChange, + label = "메뉴 아이콘", +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchText, setSearchText] = useState(""); + const [visibleCount, setVisibleCount] = useState(120); + const containerRef = useRef(null); + const scrollRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsOpen(false); + setSearchText(""); + } + }; + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + // 드롭다운 열릴 때 표시 개수 초기화 + useEffect(() => { + if (isOpen) setVisibleCount(120); + }, [isOpen]); + + // 검색어 변경 시 표시 개수 초기화 + useEffect(() => { + setVisibleCount(120); + }, [searchText]); + + const filteredIcons = useMemo(() => { + if (!searchText) return ALL_ICONS; + const lower = searchText.toLowerCase(); + return ALL_ICONS.filter( + (entry) => + entry.name.toLowerCase().includes(lower) || + entry.keywords.some((kw) => kw.includes(lower)) + ); + }, [searchText]); + + // 스크롤 끝에 도달하면 더 로드 + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + if (el.scrollTop + el.clientHeight >= el.scrollHeight - 40) { + setVisibleCount((prev) => Math.min(prev + 120, filteredIcons.length)); + } + }, [filteredIcons.length]); + + const selectedIcon = ALL_ICONS.find((e) => e.name === value); + const SelectedIconComponent = selectedIcon?.component; + + return ( +
+ +
+ + + {isOpen && ( +
+
+
+ + setSearchText(e.target.value)} + className="h-8 pl-8 text-sm" + onClick={(e) => e.stopPropagation()} + autoFocus + /> +
+
+ +
+ {!searchText && ( +

+ 총 {ALL_ICONS.length}개 아이콘 +

+ )} +
+ {filteredIcons.slice(0, visibleCount).map((entry) => { + const IconComp = entry.component; + return ( + + ); + })} +
+ {filteredIcons.length === 0 && ( +

+ 검색 결과가 없습니다. +

+ )} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 81b5ed61..f3c2ff2d 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -903,7 +903,8 @@ export const ExcelUploadModal: React.FC = ({ } } - for (const row of filteredData) { + for (let rowIdx = 0; rowIdx < filteredData.length; rowIdx++) { + const row = filteredData[rowIdx]; try { let dataToSave = { ...row }; let shouldSkip = false; @@ -925,15 +926,16 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - // 중복 발견 - 전역 설정에 따라 처리 if (duplicateAction === "skip") { shouldSkip = true; skipCount++; - console.log(`⏭️ 중복으로 건너뛰기: ${key}`); + console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); } else { shouldUpdate = true; - console.log(`🔄 중복으로 덮어쓰기: ${key}`); + console.log(`🔄 [행 ${rowIdx + 1}] 중복으로 덮어쓰기: ${key}`); } + } else { + console.log(`✅ [행 ${rowIdx + 1}] 중복 아님 (신규 데이터): ${key}`); } } @@ -943,7 +945,7 @@ export const ExcelUploadModal: React.FC = ({ } // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 - if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { + if (hasNumbering && numberingInfo && (uploadMode === "insert" || uploadMode === "upsert") && !shouldUpdate) { const existingValue = dataToSave[numberingInfo.columnName]; const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; @@ -968,24 +970,34 @@ export const ExcelUploadModal: React.FC = ({ tableName, data: dataToSave, }; + console.log(`📝 [행 ${rowIdx + 1}] 덮어쓰기 시도: id=${existingRow.id}`, dataToSave); const result = await DynamicFormApi.updateFormData(existingRow.id, formData); if (result.success) { overwriteCount++; successCount++; } else { + console.error(`❌ [행 ${rowIdx + 1}] 덮어쓰기 실패:`, result.message); failCount++; } - } else if (uploadMode === "insert") { - // 신규 등록 + } else if (uploadMode === "insert" || uploadMode === "upsert") { + // 신규 등록 (insert, upsert 모드) const formData = { screenId: 0, tableName, data: dataToSave }; + console.log(`📝 [행 ${rowIdx + 1}] 신규 등록 시도 (mode: ${uploadMode}):`, dataToSave); const result = await DynamicFormApi.saveFormData(formData); if (result.success) { successCount++; + console.log(`✅ [행 ${rowIdx + 1}] 신규 등록 성공`); } else { + console.error(`❌ [행 ${rowIdx + 1}] 신규 등록 실패:`, result.message); failCount++; } + } else if (uploadMode === "update") { + // update 모드에서 기존 데이터가 없는 행은 건너뛰기 + console.log(`⏭️ [행 ${rowIdx + 1}] update 모드: 기존 데이터 없음, 건너뛰기`); + skipCount++; } - } catch (error) { + } catch (error: any) { + console.error(`❌ [행 ${rowIdx + 1}] 업로드 처리 오류:`, error?.response?.data || error?.message || error); failCount++; } } @@ -1008,8 +1020,9 @@ export const ExcelUploadModal: React.FC = ({ } } + console.log(`📊 엑셀 업로드 결과 요약: 성공=${successCount}, 건너뛰기=${skipCount}, 덮어쓰기=${overwriteCount}, 실패=${failCount}`); + if (successCount > 0 || skipCount > 0) { - // 상세 결과 메시지 생성 let message = ""; if (successCount > 0) { message += `${successCount}개 행 업로드`; @@ -1022,15 +1035,23 @@ export const ExcelUploadModal: React.FC = ({ message += `중복 건너뛰기 ${skipCount}개`; } if (failCount > 0) { - message += ` (실패: ${failCount}개)`; + message += `, 실패 ${failCount}개`; } - toast.success(message); + if (failCount > 0 && successCount === 0) { + toast.warning(message); + } else { + toast.success(message); + } // 매핑 템플릿 저장 await saveMappingTemplateInternal(); - onSuccess?.(); + if (successCount > 0 || overwriteCount > 0) { + onSuccess?.(); + } + } else if (failCount > 0) { + toast.error(`업로드 실패: ${failCount}개 행 저장에 실패했습니다. 브라우저 콘솔에서 상세 오류를 확인하세요.`); } else { toast.error("업로드에 실패했습니다."); } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 854b1159..f75ded8f 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -18,6 +18,7 @@ import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveS import { screenApi } from "@/lib/api/screen"; import { ComponentData } from "@/types/screen"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useAuth } from "@/hooks/useAuth"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext"; @@ -548,11 +549,15 @@ export const ScreenModal: React.FC = ({ className }) => { setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용) } } else { - toast.error("데이터를 불러올 수 없습니다."); + toast.error("수정할 데이터를 불러올 수 없습니다.", { + description: "해당 항목이 삭제되었거나 접근 권한이 없을 수 있습니다.", + }); } } catch (error) { console.error("수정 데이터 조회 오류:", error); - toast.error("데이터를 불러오는 중 오류가 발생했습니다."); + showErrorToast("수정 데이터 조회에 실패했습니다", error, { + guidance: "네트워크 연결을 확인하고 다시 시도해 주세요.", + }); } } } @@ -604,7 +609,9 @@ export const ScreenModal: React.FC = ({ className }) => { } } catch (error) { console.error("화면 데이터 로딩 오류:", error); - toast.error("화면을 불러오는 중 오류가 발생했습니다."); + showErrorToast("화면 데이터를 불러오는 데 실패했습니다", error, { + guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.", + }); handleClose(); } finally { setLoading(false); diff --git a/frontend/components/dataflow/DataFlowList.tsx b/frontend/components/dataflow/DataFlowList.tsx index fa96bb99..af06af65 100644 --- a/frontend/components/dataflow/DataFlowList.tsx +++ b/frontend/components/dataflow/DataFlowList.tsx @@ -21,6 +21,7 @@ import { } from "@/components/ui/dialog"; import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useAuth } from "@/hooks/useAuth"; import { apiClient } from "@/lib/api/client"; @@ -61,7 +62,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } } catch (error) { console.error("플로우 목록 조회 실패", error); - toast.error("플로우 목록을 불러오는데 실패했습니다."); + showErrorToast("플로우 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setLoading(false); } @@ -107,7 +108,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } } catch (error) { console.error("플로우 복사 실패:", error); - toast.error("플로우 복사에 실패했습니다."); + showErrorToast("플로우 복사에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } @@ -129,7 +130,7 @@ export default function DataFlowList({ onLoadFlow }: DataFlowListProps) { } } catch (error) { console.error("플로우 삭제 실패:", error); - toast.error("플로우 삭제에 실패했습니다."); + showErrorToast("플로우 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); setShowDeleteModal(false); diff --git a/frontend/components/dataflow/external-call/ExternalCallTestPanel.tsx b/frontend/components/dataflow/external-call/ExternalCallTestPanel.tsx index 1af3beed..811e4b60 100644 --- a/frontend/components/dataflow/external-call/ExternalCallTestPanel.tsx +++ b/frontend/components/dataflow/external-call/ExternalCallTestPanel.tsx @@ -24,6 +24,7 @@ import { Timer, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; // 타입 import import { @@ -144,7 +145,7 @@ const ExternalCallTestPanel: React.FC = ({ toast.success("API 테스트가 성공했습니다!"); setActiveTab("response"); } else { - toast.error("API 호출이 실패했습니다."); + showErrorToast("API 호출이 실패했습니다", null, { guidance: "URL과 요청 설정을 확인해 주세요." }); setActiveTab("response"); } } else { @@ -156,7 +157,7 @@ const ExternalCallTestPanel: React.FC = ({ }; setTestResult(errorResult); onTestResult(errorResult); - toast.error(response.error || "테스트 실행 중 오류가 발생했습니다."); + showErrorToast("API 테스트 실행에 실패했습니다", response.error, { guidance: "URL과 요청 설정을 확인해 주세요." }); } } catch (error) { const errorResult: ApiTestResult = { @@ -167,7 +168,7 @@ const ExternalCallTestPanel: React.FC = ({ }; setTestResult(errorResult); onTestResult(errorResult); - toast.error("테스트 실행 중 오류가 발생했습니다."); + showErrorToast("API 테스트 실행에 실패했습니다", error, { guidance: "네트워크 연결과 URL을 확인해 주세요." }); } finally { setIsLoading(false); } @@ -179,7 +180,7 @@ const ExternalCallTestPanel: React.FC = ({ await navigator.clipboard.writeText(text); toast.success("클립보드에 복사되었습니다."); } catch (error) { - toast.error("복사에 실패했습니다."); + showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." }); } }, []); diff --git a/frontend/components/dataflow/node-editor/FlowEditor.tsx b/frontend/components/dataflow/node-editor/FlowEditor.tsx index 07531249..60a92803 100644 --- a/frontend/components/dataflow/node-editor/FlowEditor.tsx +++ b/frontend/components/dataflow/node-editor/FlowEditor.tsx @@ -32,6 +32,7 @@ import { LogNode } from "./nodes/LogNode"; import { EmailActionNode } from "./nodes/EmailActionNode"; import { ScriptActionNode } from "./nodes/ScriptActionNode"; import { HttpRequestActionNode } from "./nodes/HttpRequestActionNode"; +import { ProcedureCallActionNode } from "./nodes/ProcedureCallActionNode"; import { validateFlow } from "@/lib/utils/flowValidation"; import type { FlowValidation } from "@/lib/utils/flowValidation"; @@ -55,6 +56,7 @@ const nodeTypes = { emailAction: EmailActionNode, scriptAction: ScriptActionNode, httpRequestAction: HttpRequestActionNode, + procedureCallAction: ProcedureCallActionNode, // 유틸리티 comment: CommentNode, log: LogNode, diff --git a/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx b/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx new file mode 100644 index 00000000..d54478a9 --- /dev/null +++ b/frontend/components/dataflow/node-editor/nodes/ProcedureCallActionNode.tsx @@ -0,0 +1,121 @@ +"use client"; + +/** + * 프로시저/함수 호출 액션 노드 + * 내부 또는 외부 DB의 프로시저/함수를 호출하는 노드 + */ + +import { memo } from "react"; +import { Handle, Position, NodeProps } from "reactflow"; +import { Database, Workflow } from "lucide-react"; +import type { ProcedureCallActionNodeData } from "@/types/node-editor"; + +export const ProcedureCallActionNode = memo( + ({ data, selected }: NodeProps) => { + const hasProcedure = !!data.procedureName; + const inParams = data.parameters?.filter((p) => p.mode === "IN" || p.mode === "INOUT") ?? []; + const outParams = data.parameters?.filter((p) => p.mode === "OUT" || p.mode === "INOUT") ?? []; + + return ( +
+ {/* 입력 핸들 */} + + + {/* 헤더 */} +
+ +
+
+ {data.displayName || "프로시저 호출"} +
+
+
+ + {/* 본문 */} +
+ {/* DB 소스 */} +
+ + + {data.dbSource === "external" ? ( + + {data.connectionName || "외부 DB"} + + ) : ( + + 내부 DB + + )} + + + {data.callType === "function" ? "FUNCTION" : "PROCEDURE"} + +
+ + {/* 프로시저명 */} +
+ + {hasProcedure ? ( + + {data.procedureSchema && data.procedureSchema !== "public" + ? `${data.procedureSchema}.` + : ""} + {data.procedureName}() + + ) : ( + 프로시저 선택 필요 + )} +
+ + {/* 파라미터 수 */} + {hasProcedure && inParams.length > 0 && ( +
+ 입력 파라미터: {inParams.length}개 +
+ )} + + {/* 반환 필드 */} + {hasProcedure && outParams.length > 0 && ( +
+
+ 반환 필드: +
+ {outParams.map((p) => ( +
+ {p.name} + {p.dataType} +
+ ))} +
+ )} +
+ + {/* 출력 핸들 */} + +
+ ); + } +); + +ProcedureCallActionNode.displayName = "ProcedureCallActionNode"; diff --git a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx index e62bab9f..316f93b0 100644 --- a/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx +++ b/frontend/components/dataflow/node-editor/panels/PropertiesPanel.tsx @@ -23,6 +23,7 @@ import { LogProperties } from "./properties/LogProperties"; import { EmailActionProperties } from "./properties/EmailActionProperties"; import { ScriptActionProperties } from "./properties/ScriptActionProperties"; import { HttpRequestActionProperties } from "./properties/HttpRequestActionProperties"; +import { ProcedureCallActionProperties } from "./properties/ProcedureCallActionProperties"; import type { NodeType } from "@/types/node-editor"; export function PropertiesPanel() { @@ -147,6 +148,9 @@ function NodePropertiesRenderer({ node }: { node: any }) { case "httpRequestAction": return ; + case "procedureCallAction": + return ; + default: return (
@@ -185,6 +189,7 @@ function getNodeTypeLabel(type: NodeType): string { emailAction: "메일 발송", scriptAction: "스크립트 실행", httpRequestAction: "HTTP 요청", + procedureCallAction: "프로시저 호출", comment: "주석", log: "로그", }; diff --git a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx index c68ff8d4..513f0338 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/InsertActionProperties.tsx @@ -20,6 +20,7 @@ import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; import { getNumberingRules } from "@/lib/api/numberingRule"; import type { NumberingRuleConfig } from "@/types/numbering-rule"; +import { getFlowProcedureParameters } from "@/lib/api/flow"; import type { InsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -171,10 +172,19 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + // 프로시저 노드 정보를 수집하여 비동기 파라미터 조회에 사용 + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + sourcePath: string[]; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), - sourcePath: string[] = [], // 🔥 소스 경로 추적 + sourcePath: string[] = [], ): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => { if (visitedNodes.has(targetNodeId)) { console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`); @@ -366,7 +376,48 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터(반환 필드) 추가 + else if (node.type === "procedureCallAction") { + console.log("✅ 프로시저 호출 노드 발견"); + const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ + name: p.name, + label: `${p.name} (프로시저 반환)`, + sourcePath: currentPath, + }); + } + } + } + + // OUT 파라미터가 저장되어 있지 않으면 API로 동적 조회 예약 + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + sourcePath: currentPath, + }); + } + + fields.push({ + name: "_procedureReturn", + label: "프로시저 반환값", + sourcePath: currentPath, + }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`); const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath); @@ -386,31 +437,66 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP console.log(` - 총 필드 수: ${result.fields.length}개`); console.log(` - REST API 포함: ${result.hasRestAPI}`); - // 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시 - const fieldMap = new Map(); - const duplicateFields = new Set(); + const applyFields = (allFields: typeof result.fields) => { + const fieldMap = new Map(); + const duplicateFields = new Set(); - result.fields.forEach((field) => { - const key = `${field.name}`; - if (fieldMap.has(key)) { - duplicateFields.add(field.name); + allFields.forEach((field) => { + const key = `${field.name}`; + if (fieldMap.has(key)) { + duplicateFields.add(field.name); + } + fieldMap.set(key, field); + }); + + if (duplicateFields.size > 0) { + console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); } - // 중복이면 마지막 값으로 덮어씀 (기존 동작 유지) - fieldMap.set(key, field); - }); - if (duplicateFields.size > 0) { - console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`); - console.warn(" → 마지막으로 발견된 필드만 표시됩니다."); - console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!"); + const uniqueFields = Array.from(fieldMap.values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + console.log("✅ 최종 소스 필드 목록:", uniqueFields); + }; + + // 프로시저 노드에 OUT 파라미터가 저장되지 않은 경우, API로 동적 조회 + if (procedureNodes.length > 0) { + console.log(`🔄 프로시저 ${procedureNodes.length}개의 반환 필드를 API로 조회`); + applyFields(result.fields); + + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters( + pn.procedureName, + pn.dbSource, + pn.connectionId, + pn.schema + ); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ + name: p.name, + label: `${p.name} (프로시저 반환)`, + sourcePath: pn.sourcePath, + })); + } + } catch (e) { + console.error("프로시저 파라미터 조회 실패:", e); + } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) { + console.log(`✅ 프로시저 반환 필드 ${extraFields.length}개 추가 발견`); + applyFields([...result.fields, ...extraFields]); + } + }); + } else { + applyFields(result.fields); } - - const uniqueFields = Array.from(fieldMap.values()); - - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); - console.log("✅ 최종 소스 필드 목록:", uniqueFields); - console.log("✅ REST API 소스 연결:", result.hasRestAPI); }, [nodeId, nodes, edges]); /** diff --git a/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx new file mode 100644 index 00000000..ac7bd438 --- /dev/null +++ b/frontend/components/dataflow/node-editor/panels/properties/ProcedureCallActionProperties.tsx @@ -0,0 +1,641 @@ +"use client"; + +/** + * 프로시저/함수 호출 노드 속성 편집 + */ + +import { useEffect, useState, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Database, Workflow, RefreshCw, Loader2 } from "lucide-react"; +import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; +import { + getFlowProcedures, + getFlowProcedureParameters, +} from "@/lib/api/flow"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import type { ProcedureCallActionNodeData } from "@/types/node-editor"; +import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb"; + +interface ExternalConnection { + id: number; + connection_name: string; + db_type: string; +} + +interface ProcedureCallActionPropertiesProps { + nodeId: string; + data: ProcedureCallActionNodeData; +} + +export function ProcedureCallActionProperties({ + nodeId, + data, +}: ProcedureCallActionPropertiesProps) { + const { updateNode, nodes, edges } = useFlowEditorStore(); + + const [displayName, setDisplayName] = useState( + data.displayName || "프로시저 호출" + ); + const [dbSource, setDbSource] = useState<"internal" | "external">( + data.dbSource || "internal" + ); + const [connectionId, setConnectionId] = useState( + data.connectionId + ); + const [procedureName, setProcedureName] = useState( + data.procedureName || "" + ); + const [procedureSchema, setProcedureSchema] = useState( + data.procedureSchema || "public" + ); + const [callType, setCallType] = useState<"procedure" | "function">( + data.callType || "function" + ); + const [parameters, setParameters] = useState(data.parameters || []); + + const [connections, setConnections] = useState([]); + const [procedures, setProcedures] = useState([]); + const [loadingProcedures, setLoadingProcedures] = useState(false); + const [loadingParams, setLoadingParams] = useState(false); + const [sourceFields, setSourceFields] = useState< + Array<{ name: string; label?: string }> + >([]); + + // 이전 노드에서 소스 필드 목록 수집 (재귀) + useEffect(() => { + const getUpstreamFields = ( + targetId: string, + visited = new Set() + ): Array<{ name: string; label?: string }> => { + if (visited.has(targetId)) return []; + visited.add(targetId); + + const inEdges = edges.filter((e) => e.target === targetId); + const parentNodes = nodes.filter((n) => + inEdges.some((e) => e.source === n.id) + ); + const fields: Array<{ name: string; label?: string }> = []; + + for (const pNode of parentNodes) { + if ( + pNode.type === "tableSource" || + pNode.type === "externalDBSource" + ) { + const nodeFields = + (pNode.data as any).fields || + (pNode.data as any).outputFields || + []; + if (Array.isArray(nodeFields)) { + for (const f of nodeFields) { + const name = + typeof f === "string" + ? f + : f.name || f.columnName || f.field; + if (name) { + fields.push({ + name, + label: f.label || f.columnLabel || name, + }); + } + } + } + } else if (pNode.type === "dataTransform") { + const upper = getUpstreamFields(pNode.id, visited); + fields.push(...upper); + const transforms = (pNode.data as any).transformations; + if (Array.isArray(transforms)) { + for (const t of transforms) { + if (t.targetField) { + fields.push({ + name: t.targetField, + label: t.targetFieldLabel || t.targetField, + }); + } + } + } + } else if (pNode.type === "formulaTransform") { + const upper = getUpstreamFields(pNode.id, visited); + fields.push(...upper); + const transforms = (pNode.data as any).transformations; + if (Array.isArray(transforms)) { + for (const t of transforms) { + if (t.outputField) { + fields.push({ + name: t.outputField, + label: t.outputFieldLabel || t.outputField, + }); + } + } + } + } else { + fields.push(...getUpstreamFields(pNode.id, visited)); + } + } + + return fields; + }; + + const collected = getUpstreamFields(nodeId); + const unique = Array.from( + new Map(collected.map((f) => [f.name, f])).values() + ); + setSourceFields(unique); + }, [nodeId, nodes, edges]); + + useEffect(() => { + setDisplayName(data.displayName || "프로시저 호출"); + setDbSource(data.dbSource || "internal"); + setConnectionId(data.connectionId); + setProcedureName(data.procedureName || ""); + setProcedureSchema(data.procedureSchema || "public"); + setCallType(data.callType || "function"); + setParameters(data.parameters || []); + }, [data]); + + // 외부 DB 연결 목록 조회 + useEffect(() => { + if (dbSource === "external") { + ExternalDbConnectionAPI.getConnections({ is_active: "true" }) + .then((list) => + setConnections( + list.map((c: any) => ({ + id: c.id, + connection_name: c.connection_name, + db_type: c.db_type, + })) + ) + ) + .catch(console.error); + } + }, [dbSource]); + + const updateNodeData = useCallback( + (updates: Partial) => { + updateNode(nodeId, { ...data, ...updates }); + }, + [nodeId, data, updateNode] + ); + + // 프로시저 목록 조회 + const fetchProcedures = useCallback(async () => { + if (dbSource === "external" && !connectionId) return; + setLoadingProcedures(true); + try { + const res = await getFlowProcedures( + dbSource, + connectionId, + procedureSchema || undefined + ); + if (res.success && res.data) { + setProcedures(res.data); + } + } catch (e) { + console.error("프로시저 목록 조회 실패:", e); + } finally { + setLoadingProcedures(false); + } + }, [dbSource, connectionId, procedureSchema]); + + // dbSource/connectionId 변경 시 프로시저 목록 자동 조회 + useEffect(() => { + if (dbSource === "internal" || (dbSource === "external" && connectionId)) { + fetchProcedures(); + } + }, [dbSource, connectionId, fetchProcedures]); + + // 프로시저 선택 시 파라미터 조회 + const handleProcedureSelect = useCallback( + async (name: string) => { + setProcedureName(name); + + const selected = procedures.find((p) => p.name === name); + const newCallType = + selected?.type === "PROCEDURE" ? "procedure" : "function"; + setCallType(newCallType); + + updateNodeData({ + procedureName: name, + callType: newCallType, + procedureSchema, + }); + + setLoadingParams(true); + try { + const res = await getFlowProcedureParameters( + name, + dbSource, + connectionId, + procedureSchema || undefined + ); + if (res.success && res.data) { + const newParams = res.data.map((p: ProcedureParameterInfo) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode, + source: "record_field" as const, + field: "", + value: "", + })); + setParameters(newParams); + updateNodeData({ + procedureName: name, + callType: newCallType, + procedureSchema, + parameters: newParams, + }); + } + } catch (e) { + console.error("파라미터 조회 실패:", e); + } finally { + setLoadingParams(false); + } + }, + [dbSource, connectionId, procedureSchema, procedures, updateNodeData] + ); + + const handleParamChange = ( + index: number, + field: string, + value: string + ) => { + const newParams = [...parameters]; + (newParams[index] as any)[field] = value; + setParameters(newParams); + updateNodeData({ parameters: newParams }); + }; + + return ( +
+ {/* 표시명 */} +
+ + { + setDisplayName(e.target.value); + updateNodeData({ displayName: e.target.value }); + }} + placeholder="프로시저 호출" + className="h-8 text-sm" + /> +
+ + {/* DB 소스 */} +
+ + +
+ + {/* 외부 DB 연결 선택 */} + {dbSource === "external" && ( +
+ + +
+ )} + + {/* 스키마 */} +
+ +
+ setProcedureSchema(e.target.value)} + onBlur={() => { + updateNodeData({ procedureSchema }); + fetchProcedures(); + }} + placeholder="public" + className="h-8 text-sm" + /> + +
+
+ + {/* 프로시저 선택 */} +
+ + {loadingProcedures ? ( +
+ + 목록 조회 중... +
+ ) : ( + + )} +
+ + {/* 호출 타입 */} + {procedureName && ( +
+ + +
+ )} + + {/* 파라미터 매핑 */} + {procedureName && parameters.length > 0 && ( +
+ {loadingParams ? ( +
+ + 파라미터 조회 중... +
+ ) : ( + <> + {/* IN 파라미터 */} + {parameters.filter((p) => p.mode === "IN" || p.mode === "INOUT") + .length > 0 && ( +
+ +
+ {parameters.map((param, idx) => { + if (param.mode !== "IN" && param.mode !== "INOUT") + return null; + return ( + + +
+ + {param.name} + + + {param.dataType} + +
+ + {param.source === "record_field" && + (sourceFields.length > 0 ? ( + + ) : ( + + handleParamChange( + idx, + "field", + e.target.value + ) + } + placeholder="컬럼명 (이전 노드를 먼저 연결하세요)" + className="h-7 text-xs" + /> + ))} + {param.source === "static" && ( + + handleParamChange( + idx, + "value", + e.target.value + ) + } + placeholder="고정값 입력" + className="h-7 text-xs" + /> + )} + {param.source === "step_variable" && ( + + handleParamChange( + idx, + "field", + e.target.value + ) + } + placeholder="변수명" + className="h-7 text-xs" + /> + )} +
+
+ ); + })} +
+
+ )} + + {/* OUT 파라미터 (반환 필드) */} + {parameters.filter((p) => p.mode === "OUT" || p.mode === "INOUT") + .length > 0 && ( +
+ +
+
+ {parameters + .filter( + (p) => p.mode === "OUT" || p.mode === "INOUT" + ) + .map((param, idx) => ( +
+ + {param.name} + + + {param.dataType} + +
+ ))} +
+
+
+ )} + + )} +
+ )} + + {/* 안내 메시지 */} + + +
+ + 프로시저 실행 안내 +
+

+ 이 노드에 연결된 이전 노드의 데이터가 프로시저의 입력 파라미터로 + 전달됩니다. 프로시저 실행이 실패하면 전체 트랜잭션이 롤백됩니다. +

+
+
+
+ ); +} diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx index 1fd6b723..573d6fdf 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; +import { getFlowProcedureParameters } from "@/lib/api/flow"; import type { UpdateActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -165,6 +166,13 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), @@ -310,7 +318,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가 + else if (node.type === "procedureCallAction") { + const upperResult = getAllSourceFields(node.id, visitedNodes); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` }); + } + } + } + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + }); + } + fields.push({ name: "_procedureReturn", label: "프로시저 반환값" }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); @@ -323,11 +357,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP const result = getAllSourceFields(nodeId); - // 중복 제거 - const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); + const applyFields = (allFields: typeof result.fields) => { + const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + }; - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); + if (procedureNodes.length > 0) { + applyFields(result.fields); + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` })); + } + } catch (e) { console.error("프로시저 파라미터 조회 실패:", e); } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]); + }); + } else { + applyFields(result.fields); + } }, [nodeId, nodes, edges]); const loadTables = async () => { diff --git a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx index 283640d1..bcb52500 100644 --- a/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx +++ b/frontend/components/dataflow/node-editor/panels/properties/UpsertActionProperties.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import { useFlowEditorStore } from "@/lib/stores/flowEditorStore"; import { tableTypeApi } from "@/lib/api/screen"; import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections"; +import { getFlowProcedureParameters } from "@/lib/api/flow"; import type { UpsertActionNodeData } from "@/types/node-editor"; import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections"; @@ -148,6 +149,13 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP // 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색) useEffect(() => { + const procedureNodes: Array<{ + procedureName: string; + dbSource: "internal" | "external"; + connectionId?: number; + schema?: string; + }> = []; + const getAllSourceFields = ( targetNodeId: string, visitedNodes = new Set(), @@ -293,7 +301,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP foundRestAPI = foundRestAPI || upperResult.hasRestAPI; } } - // 5️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 + // 5️⃣ 프로시저 호출 노드: 상위 필드 + OUT 파라미터 추가 + else if (node.type === "procedureCallAction") { + const upperResult = getAllSourceFields(node.id, visitedNodes); + fields.push(...upperResult.fields); + foundRestAPI = foundRestAPI || upperResult.hasRestAPI; + const nodeData = node.data as any; + const procParams = nodeData.parameters; + let hasOutParams = false; + if (Array.isArray(procParams)) { + for (const p of procParams) { + if (p.mode === "OUT" || p.mode === "INOUT") { + hasOutParams = true; + fields.push({ name: p.name, label: `${p.name} (프로시저 반환)` }); + } + } + } + if (!hasOutParams && nodeData.procedureName) { + procedureNodes.push({ + procedureName: nodeData.procedureName, + dbSource: nodeData.dbSource || "internal", + connectionId: nodeData.connectionId, + schema: nodeData.procedureSchema || "public", + }); + } + fields.push({ name: "_procedureReturn", label: "프로시저 반환값" }); + } + // 6️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색 else { const upperResult = getAllSourceFields(node.id, visitedNodes); fields.push(...upperResult.fields); @@ -306,11 +340,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP const result = getAllSourceFields(nodeId); - // 중복 제거 - const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values()); + const applyFields = (allFields: typeof result.fields) => { + const uniqueFields = Array.from(new Map(allFields.map((field) => [field.name, field])).values()); + setSourceFields(uniqueFields); + setHasRestAPISource(result.hasRestAPI); + }; - setSourceFields(uniqueFields); - setHasRestAPISource(result.hasRestAPI); + if (procedureNodes.length > 0) { + applyFields(result.fields); + Promise.all( + procedureNodes.map(async (pn) => { + try { + const res = await getFlowProcedureParameters(pn.procedureName, pn.dbSource, pn.connectionId, pn.schema); + if (res.success && res.data) { + return res.data + .filter((p: any) => p.mode === "OUT" || p.mode === "INOUT") + .map((p: any) => ({ name: p.name, label: `${p.name} (프로시저 반환)` })); + } + } catch (e) { console.error("프로시저 파라미터 조회 실패:", e); } + return []; + }) + ).then((extraFieldArrays) => { + const extraFields = extraFieldArrays.flat(); + if (extraFields.length > 0) applyFields([...result.fields, ...extraFields]); + }); + } else { + applyFields(result.fields); + } }, [nodeId, nodes, edges]); // 🔥 외부 커넥션 로딩 함수 diff --git a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts index 334d150e..05cbc525 100644 --- a/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts +++ b/frontend/components/dataflow/node-editor/sidebar/nodePaletteConfig.ts @@ -132,6 +132,14 @@ export const NODE_PALETTE: NodePaletteItem[] = [ category: "external", color: "#06B6D4", // 시안 }, + { + type: "procedureCallAction", + label: "프로시저 호출", + icon: "", + description: "DB 프로시저/함수를 호출합니다", + category: "external", + color: "#8B5CF6", // 보라색 + }, // ======================================================================== // 유틸리티 diff --git a/frontend/components/flow/FlowDataListModal.tsx b/frontend/components/flow/FlowDataListModal.tsx index 61264ffb..8981e472 100644 --- a/frontend/components/flow/FlowDataListModal.tsx +++ b/frontend/components/flow/FlowDataListModal.tsx @@ -7,7 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table"; import { Checkbox } from "@/components/ui/checkbox"; import { Loader2, AlertCircle, ArrowRight } from "lucide-react"; -import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow"; +import { getStepDataList, moveBatchData, getFlowConnections } from "@/lib/api/flow"; import { toast } from "sonner"; interface FlowDataListModalProps { @@ -102,15 +102,28 @@ export function FlowDataListModal({ try { setMovingData(true); - // 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음) - const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id); + // 다음 스텝 결정 (연결 정보에서 조회) + const connResponse = await getFlowConnections(flowId); + if (!connResponse.success || !connResponse.data) { + throw new Error("플로우 연결 정보를 가져올 수 없습니다"); + } + const nextConn = connResponse.data.find((c: any) => c.fromStepId === stepId); + if (!nextConn) { + throw new Error("다음 단계가 연결되어 있지 않습니다"); + } - // 데이터 이동 API 호출 - for (const dataId of selectedDataIds) { - const response = await moveDataToNextStep(flowId, stepId, dataId); - if (!response.success) { - throw new Error(`데이터 이동 실패: ${response.message}`); - } + // 선택된 행의 ID 추출 + const selectedDataIds = Array.from(selectedRows).map((index) => String(data[index].id)); + + // 배치 이동 API 호출 + const response = await moveBatchData({ + flowId, + fromStepId: stepId, + toStepId: nextConn.toStepId, + dataIds: selectedDataIds, + }); + if (!response.success) { + throw new Error(`데이터 이동 실패: ${response.error || "알 수 없는 오류"}`); } toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`); diff --git a/frontend/components/flow/FlowNodeComponent.tsx b/frontend/components/flow/FlowNodeComponent.tsx index 8482442d..d52291bb 100644 --- a/frontend/components/flow/FlowNodeComponent.tsx +++ b/frontend/components/flow/FlowNodeComponent.tsx @@ -63,6 +63,11 @@ export const FlowNodeComponent = memo(({ data }: NodeProps) => { 단계 {data.stepOrder} + {data.integrationType === "procedure" && ( + + SP + + )}
{data.label}
@@ -75,6 +80,13 @@ export const FlowNodeComponent = memo(({ data }: NodeProps) => {
)} + {/* 프로시저 정보 */} + {data.integrationType === "procedure" && data.procedureName && ( +
+ {data.procedureName}() +
+ )} + {/* 데이터 건수 */} {data.count !== undefined && ( diff --git a/frontend/components/flow/FlowStepPanel.tsx b/frontend/components/flow/FlowStepPanel.tsx index d861f97b..311357f1 100644 --- a/frontend/components/flow/FlowStepPanel.tsx +++ b/frontend/components/flow/FlowStepPanel.tsx @@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useToast } from "@/hooks/use-toast"; -import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow"; +import { updateFlowStep, deleteFlowStep, getFlowProcedures, getFlowProcedureParameters } from "@/lib/api/flow"; import { FlowStep } from "@/types/flow"; import { FlowConditionBuilder } from "./FlowConditionBuilder"; import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; @@ -23,6 +23,10 @@ import { flowExternalDbApi } from "@/lib/api/flowExternalDb"; import { FlowExternalDbConnection, FlowExternalDbIntegrationConfig, + FlowProcedureConfig, + FlowProcedureParam, + ProcedureListItem, + ProcedureParameterInfo, INTEGRATION_TYPE_OPTIONS, OPERATION_OPTIONS, } from "@/types/flowExternalDb"; @@ -118,6 +122,13 @@ export function FlowStepPanel({ const [availableColumns, setAvailableColumns] = useState([]); const [loadingAvailableColumns, setLoadingAvailableColumns] = useState(false); + // 프로시저 관련 상태 + const [procedureList, setProcedureList] = useState([]); + const [loadingProcedures, setLoadingProcedures] = useState(false); + const [procedureParams, setProcedureParams] = useState([]); + const [loadingProcedureParams, setLoadingProcedureParams] = useState(false); + const [openProcedureCombobox, setOpenProcedureCombobox] = useState(false); + // 테이블 목록 조회 useEffect(() => { const loadTables = async () => { @@ -943,7 +954,7 @@ export function FlowStepPanel({ {opt.label} @@ -1262,6 +1273,370 @@ export function FlowStepPanel({ )} )} + + {/* 프로시저/함수 호출 설정 */} + {formData.integrationType === "procedure" && ( +
+ {/* DB 소스 선택 */} +
+ + +
+ + {/* 외부 DB 연결 선택 */} + {(formData.integrationConfig as FlowProcedureConfig)?.dbSource === "external" && ( +
+ + {externalConnections.length === 0 ? ( +
+

+ 등록된 외부 DB 연결이 없습니다. 먼저 외부 DB 연결을 추가해주세요. +

+
+ ) : ( + + )} +
+ )} + + {/* 프로시저 선택 */} + {((formData.integrationConfig as FlowProcedureConfig)?.dbSource === "internal" || + (formData.integrationConfig as FlowProcedureConfig)?.connectionId) && ( + <> +
+ + {loadingProcedures ? ( +
로딩 중...
+ ) : ( + + + + + + + + + + 프로시저를 찾을 수 없습니다. + + + {procedureList.map((proc) => ( + { + const procConfig = formData.integrationConfig as FlowProcedureConfig; + const newConfig: FlowProcedureConfig = { + ...procConfig, + procedureName: proc.name, + procedureSchema: proc.schema, + callType: proc.type === "PROCEDURE" ? "procedure" : "function", + parameters: [], + }; + setFormData({ ...formData, integrationConfig: newConfig }); + setOpenProcedureCombobox(false); + + setLoadingProcedureParams(true); + try { + const res = await getFlowProcedureParameters( + proc.name, + procConfig.dbSource, + procConfig.connectionId, + proc.schema, + ); + if (res.success && res.data) { + setProcedureParams(res.data); + const mappedParams: FlowProcedureParam[] = res.data.map((p) => ({ + name: p.name, + dataType: p.dataType, + mode: p.mode, + source: "record_field" as const, + field: "", + value: "", + })); + setFormData((prev) => ({ + ...prev, + integrationConfig: { + ...(prev.integrationConfig as FlowProcedureConfig), + procedureName: proc.name, + procedureSchema: proc.schema, + callType: proc.type === "PROCEDURE" ? "procedure" : "function", + parameters: mappedParams, + }, + })); + } + } catch (e) { + console.error("파라미터 조회 실패:", e); + } finally { + setLoadingProcedureParams(false); + } + }} + className="text-xs sm:text-sm" + > + +
+ {proc.name} + + {proc.type} | {proc.schema} + {proc.returnType ? ` | 반환: ${proc.returnType}` : ""} + +
+
+ ))} +
+
+
+
+
+ )} + {procedureList.length === 0 && !loadingProcedures && ( + + )} +
+ + {/* 호출 타입 */} + {(formData.integrationConfig as FlowProcedureConfig)?.procedureName && ( +
+ + +
+ )} + + {/* 파라미터 매핑 테이블 */} + {(formData.integrationConfig as FlowProcedureConfig)?.procedureName && ( +
+ + {loadingProcedureParams ? ( +
파라미터 로딩 중...
+ ) : (formData.integrationConfig as FlowProcedureConfig)?.parameters?.length === 0 ? ( +
+

파라미터가 없는 프로시저입니다.

+
+ ) : ( +
+ {(formData.integrationConfig as FlowProcedureConfig)?.parameters?.map((param, idx) => ( +
+
+ {param.name} + + {param.dataType} | {param.mode} + +
+ {param.mode !== "OUT" && ( +
+
+ + +
+
+ + { + const params = [...(formData.integrationConfig as FlowProcedureConfig).parameters]; + if (param.source === "static") { + params[idx] = { ...params[idx], value: e.target.value }; + } else { + params[idx] = { ...params[idx], field: e.target.value }; + } + setFormData({ + ...formData, + integrationConfig: { + ...(formData.integrationConfig as FlowProcedureConfig), + parameters: params, + }, + }); + }} + /> +
+
+ )} +
+ ))} +
+ )} +
+ )} + +
+

+ 프로시저는 데이터 이동 전에 실행됩니다. +
실패 시 데이터 이동도 함께 롤백됩니다. +

+
+ + )} +
+ )} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index de2c5b61..5223bc39 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -45,6 +45,7 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { CompanySwitcher } from "@/components/admin/CompanySwitcher"; +import { getIconComponent } from "@/components/admin/MenuIconPicker"; // useAuth의 UserInfo 타입을 확장 interface ExtendedUserInfo { @@ -74,8 +75,13 @@ interface AppLayoutProps { children: React.ReactNode; } -// 메뉴 아이콘 매핑 함수 -const getMenuIcon = (menuName: string) => { +// 메뉴 아이콘 매핑 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback) +const getMenuIcon = (menuName: string, dbIconName?: string | null) => { + if (dbIconName) { + const DbIcon = getIconComponent(dbIconName); + if (DbIcon) return ; + } + const name = menuName.toLowerCase(); if (name.includes("대시보드") || name.includes("dashboard")) return ; if (name.includes("관리자") || name.includes("admin")) return ; @@ -205,7 +211,7 @@ const convertSingleMenu = (menu: MenuItem, allMenus: MenuItem[], userInfo: Exten return { id: menuId, name: getDisplayText(menu), - icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || ""), + icon: getMenuIcon(menu.menu_name_kor || menu.MENU_NAME_KOR || "", menu.menu_icon || menu.MENU_ICON), url: menu.menu_url || menu.MENU_URL || "#", children: children.length > 0 ? children : undefined, hasChildren: children.length > 0, diff --git a/frontend/components/layout/MainSidebar.tsx b/frontend/components/layout/MainSidebar.tsx index 97721831..e544c750 100644 --- a/frontend/components/layout/MainSidebar.tsx +++ b/frontend/components/layout/MainSidebar.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBr import { cn } from "@/lib/utils"; import { MenuItem } from "@/types/menu"; import { MENU_ICONS, MESSAGES } from "@/constants/layout"; +import { getIconComponent } from "@/components/admin/MenuIconPicker"; interface MainSidebarProps { menuList: MenuItem[]; @@ -11,9 +12,14 @@ interface MainSidebarProps { } /** - * 메뉴 아이콘 선택 함수 + * 메뉴 아이콘 선택 함수 (DB 아이콘 우선, 없으면 키워드 기반 fallback) */ -const getMenuIcon = (menuName: string) => { +const getMenuIcon = (menuName: string, dbIconName?: string | null) => { + if (dbIconName) { + const DbIcon = getIconComponent(dbIconName); + if (DbIcon) return ; + } + if (MENU_ICONS.HOME.some((keyword) => menuName.includes(keyword))) { return ; } @@ -57,7 +63,7 @@ export function MainSidebar({ menuList, expandedMenus, onMenuClick, className = )} >
- {getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "")} + {getMenuIcon(menu.MENU_NAME_KOR || menu.menuNameKor || "", menu.MENU_ICON || menu.menu_icon)} {menu.MENU_NAME_KOR || menu.menuNameKor || "메뉴"}
{hasChildren && (isExpanded ? : )} diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 8b521fe0..fbae903f 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, Trash2, FolderTree, Check, ChevronsUpDown } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; import { NumberingRulePreview } from "./NumberingRulePreview"; @@ -399,10 +400,10 @@ export const NumberingRuleDesigner: React.FC = ({ await onSave?.(response.data); toast.success("채번 규칙이 저장되었습니다"); } else { - toast.error(response.error || "저장 실패"); + showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(`저장 실패: ${error.message}`); + showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); } finally { setLoading(false); } @@ -446,10 +447,10 @@ export const NumberingRuleDesigner: React.FC = ({ toast.success("규칙이 삭제되었습니다"); } else { - toast.error(response.error || "삭제 실패"); + showErrorToast("채번 규칙 삭제에 실패했습니다", response.error, { guidance: "잠시 후 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(`삭제 실패: ${error.message}`); + showErrorToast("채번 규칙 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/components/pop/management/PopScreenSettingModal.tsx b/frontend/components/pop/management/PopScreenSettingModal.tsx index 7dd7a11e..c57d6d52 100644 --- a/frontend/components/pop/management/PopScreenSettingModal.tsx +++ b/frontend/components/pop/management/PopScreenSettingModal.tsx @@ -33,6 +33,7 @@ import { Save, } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import { PopScreenGroup, getPopScreenGroups } from "@/lib/api/popScreenGroup"; @@ -191,7 +192,7 @@ export function PopScreenSettingModal({ onOpenChange(false); } catch (error) { console.error("저장 실패:", error); - toast.error("저장에 실패했습니다."); + showErrorToast("POP 화면 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setSaving(false); } diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 7a9a3ff3..4d8a6fec 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -15,6 +15,7 @@ import { ko } from "date-fns/locale"; import { useAuth } from "@/hooks/useAuth"; import { uploadFilesAndCreateData } from "@/lib/api/file"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig, LayerDefinition } from "@/types/screen-management"; import { @@ -1265,7 +1266,7 @@ export const InteractiveScreenViewer: React.FC = ( } } catch (error) { // console.error("파일 업로드 오류:", error); - toast.error("파일 업로드에 실패했습니다."); + showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." }); // 파일 입력 초기화 e.target.value = ""; diff --git a/frontend/components/screen/MenuAssignmentModal.tsx b/frontend/components/screen/MenuAssignmentModal.tsx index fddf0bcc..55e57f74 100644 --- a/frontend/components/screen/MenuAssignmentModal.tsx +++ b/frontend/components/screen/MenuAssignmentModal.tsx @@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Search, Monitor, Settings, X, Plus } from "lucide-react"; import { menuScreenApi } from "@/lib/api/screen"; import { apiClient } from "@/lib/api/client"; @@ -93,7 +94,7 @@ export const MenuAssignmentModal: React.FC = ({ setMenus(allMenus); } catch (error) { // console.error("메뉴 목록 로드 실패:", error); - toast.error("메뉴 목록을 불러오는데 실패했습니다."); + showErrorToast("메뉴 목록을 불러오는 데 실패했습니다", error, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/components/screen/NodeSettingModal.tsx b/frontend/components/screen/NodeSettingModal.tsx index 5ef1d612..287dcaff 100644 --- a/frontend/components/screen/NodeSettingModal.tsx +++ b/frontend/components/screen/NodeSettingModal.tsx @@ -41,6 +41,7 @@ import { CommandList, } from "@/components/ui/command"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { cn } from "@/lib/utils"; import { Badge } from "@/components/ui/badge"; import { @@ -403,7 +404,7 @@ export default function NodeSettingModal({ ]); toast.success("데이터가 새로고침되었습니다."); } catch (error) { - toast.error("새로고침 실패"); + showErrorToast("새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } @@ -635,10 +636,10 @@ function TableRelationTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "저장에 실패했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "저장 중 오류가 발생했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; @@ -653,10 +654,10 @@ function TableRelationTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "삭제에 실패했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "삭제 중 오류가 발생했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; @@ -1178,10 +1179,10 @@ function JoinSettingTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "저장에 실패했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "저장 중 오류가 발생했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; @@ -1196,10 +1197,10 @@ function JoinSettingTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "삭제에 실패했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "삭제 중 오류가 발생했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; @@ -1586,10 +1587,10 @@ function DataFlowTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "저장에 실패했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", response.message, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "저장 중 오류가 발생했습니다."); + showErrorToast("노드 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; @@ -1604,10 +1605,10 @@ function DataFlowTab({ onReload(); onRefreshVisualization?.(); } else { - toast.error(response.message || "삭제에 실패했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." }); } } catch (error: any) { - toast.error(error.message || "삭제 중 오류가 발생했습니다."); + showErrorToast("노드 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/components/screen/SaveModal.tsx b/frontend/components/screen/SaveModal.tsx index 1c848d6a..259bf238 100644 --- a/frontend/components/screen/SaveModal.tsx +++ b/frontend/components/screen/SaveModal.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Button } from "@/components/ui/button"; import { X, Save, Loader2 } from "lucide-react"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { InteractiveScreenViewer } from "./InteractiveScreenViewer"; import { screenApi } from "@/lib/api/screen"; @@ -76,7 +77,9 @@ export const SaveModal: React.FC = ({ } } catch (error) { console.error("화면 로드 실패:", error); - toast.error("화면을 불러오는데 실패했습니다."); + showErrorToast("화면 구성 정보를 불러오는 데 실패했습니다", error, { + guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.", + }); } finally { setLoading(false); } @@ -264,7 +267,9 @@ export const SaveModal: React.FC = ({ } catch (error: any) { // ❌ 저장 실패 - 모달은 닫히지 않음 console.error("저장 실패:", error); - toast.error(`저장 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`); + showErrorToast("데이터 저장에 실패했습니다", error, { + guidance: "입력 값을 확인하고 다시 시도해 주세요.", + }); } finally { setIsSaving(false); } diff --git a/frontend/components/screen/ScreenGroupModal.tsx b/frontend/components/screen/ScreenGroupModal.tsx index 3cf0759a..2726ce04 100644 --- a/frontend/components/screen/ScreenGroupModal.tsx +++ b/frontend/components/screen/ScreenGroupModal.tsx @@ -22,6 +22,7 @@ import { } from "@/components/ui/select"; import { ScreenGroup, createScreenGroup, updateScreenGroup } from "@/lib/api/screenGroup"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { apiClient } from "@/lib/api/client"; import { Command, @@ -225,11 +226,11 @@ export function ScreenGroupModal({ onSuccess(); onClose(); } else { - toast.error(response.message || "작업에 실패했습니다"); + showErrorToast("그룹 저장에 실패했습니다", response.message, { guidance: "잠시 후 다시 시도해 주세요." }); } } catch (error: any) { console.error("그룹 저장 실패:", error); - toast.error("그룹 저장에 실패했습니다"); + showErrorToast("그룹 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setLoading(false); } diff --git a/frontend/components/screen/ScreenGroupTreeView.tsx b/frontend/components/screen/ScreenGroupTreeView.tsx index 1aa47f0d..e8b56b36 100644 --- a/frontend/components/screen/ScreenGroupTreeView.tsx +++ b/frontend/components/screen/ScreenGroupTreeView.tsx @@ -89,6 +89,7 @@ import { Check, ChevronsUpDown } from "lucide-react"; import { ScreenGroupModal } from "./ScreenGroupModal"; import CopyScreenModal from "./CopyScreenModal"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { screenApi } from "@/lib/api/screen"; interface ScreenGroupTreeViewProps { @@ -581,11 +582,11 @@ export function ScreenGroupTreeView({ await loadGroupsData(); window.dispatchEvent(new CustomEvent("screen-list-refresh")); } else { - toast.error(response.message || "그룹 삭제에 실패했습니다"); + showErrorToast("그룹 삭제에 실패했습니다", response.message, { guidance: "하위 항목이 있는 경우 먼저 삭제해 주세요." }); } } catch (error) { console.error("그룹 삭제 실패:", error); - toast.error("그룹 삭제에 실패했습니다"); + showErrorToast("그룹 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setIsDeleting(false); setDeleteProgress({ current: 0, total: 0, message: "" }); @@ -614,7 +615,7 @@ export function ScreenGroupTreeView({ window.dispatchEvent(new CustomEvent("screen-list-refresh")); } catch (error) { console.error("화면 삭제 실패:", error); - toast.error("화면 삭제에 실패했습니다"); + showErrorToast("화면 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setIsScreenDeleting(false); setIsScreenDeleteDialogOpen(false); @@ -765,7 +766,7 @@ export function ScreenGroupTreeView({ window.dispatchEvent(new CustomEvent("screen-list-refresh")); } catch (error) { console.error("화면 수정 실패:", error); - toast.error("화면 수정에 실패했습니다"); + showErrorToast("화면 정보 수정에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } finally { setIsEditScreenModalOpen(false); setEditingScreen(null); diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 2271c96f..1f1853be 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -53,6 +53,7 @@ import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeU import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { RealtimePreview } from "./RealtimePreviewDynamic"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; +import { showErrorToast } from "@/lib/utils/toastUtils"; // InteractiveScreenViewer를 동적으로 import (SSR 비활성화) const InteractiveScreenViewer = dynamic( @@ -683,7 +684,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setPreviewLayout(layoutData); } catch (error) { console.error("❌ 레이아웃 로드 실패:", error); - toast.error("화면 레이아웃을 불러오는데 실패했습니다."); + showErrorToast("화면 레이아웃을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); } finally { setIsLoadingPreview(false); } diff --git a/frontend/components/screen/SimpleScreenDesigner.tsx b/frontend/components/screen/SimpleScreenDesigner.tsx index 6f76a112..d748bb8c 100644 --- a/frontend/components/screen/SimpleScreenDesigner.tsx +++ b/frontend/components/screen/SimpleScreenDesigner.tsx @@ -12,6 +12,7 @@ import { import { generateComponentId } from "@/lib/utils/generateId"; import { screenApi } from "@/lib/api/screen"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { RealtimePreview } from "./RealtimePreviewDynamic"; import DesignerToolbar from "./DesignerToolbar"; @@ -53,7 +54,7 @@ export default function SimpleScreenDesigner({ selectedScreen, onBackToList }: S toast.success("화면이 저장되었습니다."); } catch (error) { // console.error("저장 실패:", error); - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("화면 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setIsSaving(false); } diff --git a/frontend/components/screen/panels/FieldJoinPanel.tsx b/frontend/components/screen/panels/FieldJoinPanel.tsx index 86ebc226..42e71717 100644 --- a/frontend/components/screen/panels/FieldJoinPanel.tsx +++ b/frontend/components/screen/panels/FieldJoinPanel.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Plus, Pencil, Trash2, Link2, Database } from "lucide-react"; import { getFieldJoins, @@ -155,7 +156,7 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel toast.error(response.message || "저장에 실패했습니다."); } } catch (error) { - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("필드 조인 설정 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } }; @@ -172,7 +173,7 @@ export default function FieldJoinPanel({ screenId, componentId, layoutId }: Fiel toast.error(response.message || "삭제에 실패했습니다."); } } catch (error) { - toast.error("삭제 중 오류가 발생했습니다."); + showErrorToast("필드 조인 설정 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }; diff --git a/frontend/components/screen/panels/FileComponentConfigPanel.tsx b/frontend/components/screen/panels/FileComponentConfigPanel.tsx index f14c861f..21c21956 100644 --- a/frontend/components/screen/panels/FileComponentConfigPanel.tsx +++ b/frontend/components/screen/panels/FileComponentConfigPanel.tsx @@ -14,6 +14,7 @@ import { FileInfo, FileUploadResponse } from "@/lib/registry/components/file-upl import { uploadFiles, downloadFile, deleteFile } from "@/lib/api/file"; import { formatFileSize, cn } from "@/lib/utils"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; interface FileComponentConfigPanelProps { component: FileComponent; @@ -536,7 +537,7 @@ export const FileComponentConfigPanel: React.FC = // fieldName // }); toast.dismiss(); - toast.error(`파일 업로드에 실패했습니다: ${error?.message || '알 수 없는 오류'}`); + showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." }); } finally { // console.log("🏁 파일 업로드 완료, 로딩 상태 해제"); setUploading(false); @@ -554,7 +555,7 @@ export const FileComponentConfigPanel: React.FC = toast.success(`${file.realFileName || file.name} 다운로드가 완료되었습니다.`); } catch (error) { // console.error('파일 다운로드 오류:', error); - toast.error('파일 다운로드에 실패했습니다.'); + showErrorToast("파일 다운로드에 실패했습니다", error, { guidance: "파일이 존재하는지 확인하고 다시 시도해 주세요." }); } }, []); @@ -677,7 +678,7 @@ export const FileComponentConfigPanel: React.FC = toast.success('파일이 삭제되었습니다.'); } catch (error) { // console.error('파일 삭제 오류:', error); - toast.error('파일 삭제에 실패했습니다.'); + showErrorToast("파일 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }, [uploadedFiles, onUpdateProperty, component.id]); @@ -713,7 +714,7 @@ export const FileComponentConfigPanel: React.FC = toast.success(`${uploadedFiles.length}개 파일이 영구 저장되었습니다.`); } catch (error) { // console.error('파일 저장 오류:', error); - toast.error('파일 저장에 실패했습니다.'); + showErrorToast("파일 저장에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." }); } }, [uploadedFiles, onUpdateProperty, component.id, setGlobalFileState]); diff --git a/frontend/components/screen/widgets/FlowWidget.tsx b/frontend/components/screen/widgets/FlowWidget.tsx index 5564a14d..ecb189c3 100644 --- a/frontend/components/screen/widgets/FlowWidget.tsx +++ b/frontend/components/screen/widgets/FlowWidget.tsx @@ -36,6 +36,7 @@ import { SingleTableWithSticky } from "@/lib/registry/components/table-list/Sing import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { Pagination, PaginationContent, @@ -265,7 +266,7 @@ export function FlowWidget({ setSearchValues({}); } catch (error) { console.error("필터 설정 저장 실패:", error); - toast.error("설정 저장에 실패했습니다"); + showErrorToast("필터 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }, [filterSettingKey, searchFilterColumns]); @@ -309,7 +310,7 @@ export function FlowWidget({ toast.success("그룹 설정이 저장되었습니다"); } catch (error) { console.error("그룹 설정 저장 실패:", error); - toast.error("설정 저장에 실패했습니다"); + showErrorToast("그룹 설정 저장에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }, [groupSettingKey, groupByColumns]); @@ -514,7 +515,7 @@ export function FlowWidget({ } } catch (err: any) { console.error("❌ 플로우 새로고침 실패:", err); - toast.error(err.message || "데이터를 새로고치는데 실패했습니다"); + showErrorToast("데이터 새로고침에 실패했습니다", err, { guidance: "네트워크 연결을 확인하고 다시 시도해 주세요." }); } finally { if (selectedStepId) { setStepDataLoading(false); @@ -747,7 +748,7 @@ export function FlowWidget({ } } catch (err: any) { console.error("Failed to load step data:", err); - toast.error(err.message || "데이터를 불러오는데 실패했습니다"); + showErrorToast("스텝 데이터를 불러오는 데 실패했습니다", err, { guidance: "네트워크 연결을 확인해 주세요." }); } finally { setStepDataLoading(false); } @@ -1023,7 +1024,7 @@ export function FlowWidget({ toast.success(`${exportData.length}개 행이 Excel로 내보내기 되었습니다.`); } catch (error) { console.error("Excel 내보내기 오류:", error); - toast.error("Excel 내보내기에 실패했습니다."); + showErrorToast("Excel 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); @@ -1188,7 +1189,7 @@ export function FlowWidget({ toast.success(`${exportData.length}개 행이 PDF로 내보내기 되었습니다.`, { id: "pdf-export" }); } catch (error) { console.error("PDF 내보내기 오류:", error); - toast.error("PDF 내보내기에 실패했습니다.", { id: "pdf-export" }); + showErrorToast("PDF 파일 내보내기에 실패했습니다", error, { guidance: "데이터를 확인하고 다시 시도해 주세요." }); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, flowName, getRowKey]); @@ -1216,7 +1217,7 @@ export function FlowWidget({ toast.success(`${copyData.length}개 행이 클립보드에 복사되었습니다.`); } catch (error) { console.error("복사 오류:", error); - toast.error("복사에 실패했습니다."); + showErrorToast("클립보드 복사에 실패했습니다", error, { guidance: "브라우저 권한을 확인해 주세요." }); } }, [sortedDisplayData, selectedRows, stepDataColumns, columnLabels, getRowKey]); @@ -1318,7 +1319,7 @@ export function FlowWidget({ toast.success("데이터를 새로고침했습니다."); } catch (error) { console.error("새로고침 오류:", error); - toast.error("새로고침에 실패했습니다."); + showErrorToast("데이터 새로고침에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } finally { setStepDataLoading(false); } @@ -1399,7 +1400,7 @@ export function FlowWidget({ } } catch (error) { console.error("편집 저장 오류:", error); - toast.error("저장 중 오류가 발생했습니다."); + showErrorToast("데이터 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); } cancelEditing(); diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index b60617e6..f6f1fc6b 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -48,6 +48,7 @@ export const V2Repeater: React.FC = ({ onRowClick, className, formData: parentFormData, + groupedData, ...restProps }) => { // componentId 결정: 직접 전달 또는 component 객체에서 추출 @@ -419,65 +420,113 @@ export const V2Repeater: React.FC = ({ fkValue, }); - const response = await apiClient.post( - `/table-management/tables/${config.mainTableName}/data`, - { + let rows: any[] = []; + const useEntityJoinForLoad = config.sourceDetailConfig?.useEntityJoin; + + if (useEntityJoinForLoad) { + // 엔티티 조인을 사용하여 데이터 로드 (part_code → item_info 자동 조인) + const searchParam = JSON.stringify({ [config.foreignKeyColumn!]: fkValue }); + const params: Record = { page: 1, size: 1000, - dataFilter: { - enabled: true, - filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], - }, - autoFilter: true, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + const addJoinCols = config.sourceDetailConfig?.additionalJoinColumns; + if (addJoinCols && addJoinCols.length > 0) { + params.additionalJoinColumns = JSON.stringify(addJoinCols); } - ); + const response = await apiClient.get( + `/table-management/tables/${config.mainTableName}/data-with-joins`, + { params } + ); + const resultData = response.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어날 수 있으므로 id 기준 중복 제거 + const seenIds = new Set(); + rows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + const response = await apiClient.post( + `/table-management/tables/${config.mainTableName}/data`, + { + page: 1, + size: 1000, + dataFilter: { + enabled: true, + filters: [{ columnName: config.foreignKeyColumn, operator: "equals", value: fkValue }], + }, + autoFilter: true, + } + ); + rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + } - const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; if (Array.isArray(rows) && rows.length > 0) { - console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); + console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`, useEntityJoinForLoad ? "(엔티티 조인)" : ""); - // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 - const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); - const sourceTable = config.dataSource?.sourceTable; - const fkColumn = config.dataSource?.foreignKey; - const refKey = config.dataSource?.referenceKey || "id"; + // 엔티티 조인 사용 시: columnMapping으로 _display_ 필드 보강 + const columnMapping = config.sourceDetailConfig?.columnMapping; + if (useEntityJoinForLoad && columnMapping) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + rows.forEach((row: any) => { + sourceDisplayColumns.forEach((col) => { + const mappedKey = columnMapping[col.key]; + const value = mappedKey ? row[mappedKey] : row[col.key]; + row[`_display_${col.key}`] = value ?? ""; + }); + }); + console.log("✅ [V2Repeater] 엔티티 조인 표시 데이터 보강 완료"); + } - if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { - try { - const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); - const uniqueValues = [...new Set(fkValues)]; + // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 (엔티티 조인 미사용 시) + if (!useEntityJoinForLoad) { + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + const sourceTable = config.dataSource?.sourceTable; + const fkColumn = config.dataSource?.foreignKey; + const refKey = config.dataSource?.referenceKey || "id"; - if (uniqueValues.length > 0) { - // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 - const sourcePromises = uniqueValues.map((val) => - apiClient.post(`/table-management/tables/${sourceTable}/data`, { - page: 1, size: 1, - search: { [refKey]: val }, - autoFilter: true, - }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) - .catch(() => []) - ); - const sourceResults = await Promise.all(sourcePromises); - const sourceMap = new Map(); - sourceResults.flat().forEach((sr: any) => { - if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); - }); + if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { + try { + const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); + const uniqueValues = [...new Set(fkValues)]; - // 각 행에 소스 테이블의 표시 데이터 병합 - rows.forEach((row: any) => { - const sourceRecord = sourceMap.get(String(row[fkColumn])); - if (sourceRecord) { - sourceDisplayColumns.forEach((col) => { - const displayValue = sourceRecord[col.key] ?? null; - row[col.key] = displayValue; - row[`_display_${col.key}`] = displayValue; - }); - } - }); - console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + if (uniqueValues.length > 0) { + const sourcePromises = uniqueValues.map((val) => + apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, size: 1, + search: { [refKey]: val }, + autoFilter: true, + }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) + .catch(() => []) + ); + const sourceResults = await Promise.all(sourcePromises); + const sourceMap = new Map(); + sourceResults.flat().forEach((sr: any) => { + if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); + }); + + rows.forEach((row: any) => { + const sourceRecord = sourceMap.get(String(row[fkColumn])); + if (sourceRecord) { + sourceDisplayColumns.forEach((col) => { + const displayValue = sourceRecord[col.key] ?? null; + row[col.key] = displayValue; + row[`_display_${col.key}`] = displayValue; + }); + } + }); + console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + } + } catch (sourceError) { + console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } - } catch (sourceError) { - console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); } } @@ -964,8 +1013,113 @@ export const V2Repeater: React.FC = ({ [], ); - // V2Repeater는 자체 데이터 관리 (아이템 선택 모달, useCustomTable 로딩, DataReceiver)를 사용. - // EditModal의 groupedData는 메인 테이블 레코드이므로 V2Repeater에서는 사용하지 않음. + // sourceDetailConfig가 설정되고 groupedData(모달에서 전달된 마스터 데이터)가 있으면 + // 마스터의 키를 추출하여 디테일 테이블에서 행을 조회 → 리피터에 자동 세팅 + const sourceDetailLoadedRef = useRef(false); + useEffect(() => { + if (sourceDetailLoadedRef.current) return; + if (!groupedData || groupedData.length === 0) return; + if (!config.sourceDetailConfig) return; + + const { tableName, foreignKey, parentKey } = config.sourceDetailConfig; + if (!tableName || !foreignKey || !parentKey) return; + + const parentKeys = groupedData + .map((row) => row[parentKey]) + .filter((v) => v !== undefined && v !== null && v !== ""); + + if (parentKeys.length === 0) return; + + sourceDetailLoadedRef.current = true; + + const loadSourceDetails = async () => { + try { + const uniqueKeys = [...new Set(parentKeys)] as string[]; + const { useEntityJoin, columnMapping, additionalJoinColumns } = config.sourceDetailConfig!; + + let detailRows: any[] = []; + + if (useEntityJoin) { + // data-with-joins GET API 사용 (엔티티 조인 자동 적용) + const searchParam = JSON.stringify({ [foreignKey]: uniqueKeys.join("|") }); + const params: Record = { + page: 1, + size: 9999, + search: searchParam, + enableEntityJoin: true, + autoFilter: JSON.stringify({ enabled: true }), + }; + if (additionalJoinColumns && additionalJoinColumns.length > 0) { + params.additionalJoinColumns = JSON.stringify(additionalJoinColumns); + } + const resp = await apiClient.get(`/table-management/tables/${tableName}/data-with-joins`, { params }); + const resultData = resp.data?.data; + const rawRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + // 엔티티 조인 시 참조 테이블에 중복 레코드가 있으면 행이 늘어나므로 id 기준 중복 제거 + const seenIds = new Set(); + detailRows = rawRows.filter((row: any) => { + if (!row.id || seenIds.has(row.id)) return false; + seenIds.add(row.id); + return true; + }); + } else { + // 기존 POST API 사용 + const resp = await apiClient.post(`/table-management/tables/${tableName}/data`, { + page: 1, + size: 9999, + search: { [foreignKey]: uniqueKeys }, + }); + const resultData = resp.data?.data; + detailRows = Array.isArray(resultData) + ? resultData + : resultData?.data || resultData?.rows || []; + } + + if (detailRows.length === 0) { + console.warn("[V2Repeater] sourceDetail 조회 결과 없음:", { tableName, uniqueKeys }); + return; + } + + console.log("[V2Repeater] sourceDetail 조회 완료:", detailRows.length, "건", useEntityJoin ? "(엔티티 조인)" : ""); + + // 디테일 행을 리피터 컬럼에 매핑 + const newRows = detailRows.map((detail, index) => { + const row: any = { _id: `src_detail_${Date.now()}_${index}` }; + for (const col of config.columns) { + if (col.isSourceDisplay) { + // columnMapping이 있으면 조인 alias에서 값 가져오기 (표시용) + const mappedKey = columnMapping?.[col.key]; + const value = mappedKey ? detail[mappedKey] : detail[col.key]; + row[`_display_${col.key}`] = value ?? ""; + // 원본 값도 저장 (DB persist용 - _display_ 접두사 없이) + if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } + } else if (col.autoFill) { + const autoValue = generateAutoFillValueSync(col, index, parentFormData); + row[col.key] = autoValue ?? ""; + } else if (col.sourceKey && detail[col.sourceKey] !== undefined) { + row[col.key] = detail[col.sourceKey]; + } else if (detail[col.key] !== undefined) { + row[col.key] = detail[col.key]; + } else { + row[col.key] = ""; + } + } + return row; + }); + + setData(newRows); + onDataChange?.(newRows); + } catch (error) { + console.error("[V2Repeater] sourceDetail 조회 실패:", error); + } + }; + + loadSourceDetails(); + }, [groupedData, config.sourceDetailConfig, config.columns, generateAutoFillValueSync, parentFormData, onDataChange]); // parentSequence 컬럼의 부모 필드 값이 변경되면 행 데이터 갱신 useEffect(() => { diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 690791d5..538d33be 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -80,7 +80,7 @@ const DropdownSelect = forwardRef {options - .filter((option) => option.value !== "") + .filter((option) => option.value != null && option.value !== "") .map((option) => ( {option.label} @@ -112,6 +112,12 @@ const DropdownSelect = forwardRef + options.filter((o) => o.value != null && o.value !== ""), + [options] + ); + const selectedValues = useMemo(() => { if (!value) return []; return Array.isArray(value) ? value : [value]; @@ -119,9 +125,9 @@ const DropdownSelect = forwardRef { return selectedValues - .map((v) => options.find((o) => o.value === v)?.label) + .map((v) => safeOptions.find((o) => o.value === v)?.label) .filter(Boolean) as string[]; - }, [selectedValues, options]); + }, [selectedValues, safeOptions]); const handleSelect = useCallback((selectedValue: string) => { if (multiple) { @@ -191,7 +197,7 @@ const DropdownSelect = forwardRef { if (!search) return 1; - const option = options.find((o) => o.value === itemValue); + const option = safeOptions.find((o) => o.value === itemValue); const label = (option?.label || option?.value || "").toLowerCase(); if (label.includes(search.toLowerCase())) return 1; return 0; @@ -201,7 +207,7 @@ const DropdownSelect = forwardRef 검색 결과가 없습니다. - {options.map((option) => { + {safeOptions.map((option) => { const displayLabel = option.label || option.value || "(빈 값)"; return ( ( } } - setOptions(fetchedOptions); + // null/undefined value 필터링 (cmdk 크래시 방지) + const sanitized = fetchedOptions.filter( + (o) => o.value != null && String(o.value) !== "" + ).map((o) => ({ ...o, value: String(o.value), label: o.label || String(o.value) })); + setOptions(sanitized); setOptionsLoaded(true); } catch (error) { console.error("옵션 로딩 실패:", error); diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 1f89ae12..66f0f18b 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -31,6 +31,7 @@ import { Wand2, Check, ChevronsUpDown, + ListTree, } from "lucide-react"; import { Command, @@ -983,6 +984,133 @@ export const V2RepeaterConfigPanel: React.FC = ({ + {/* 소스 디테일 자동 조회 설정 */} +
+
+ { + if (checked) { + updateConfig({ + sourceDetailConfig: { + tableName: "", + foreignKey: "", + parentKey: "", + }, + }); + } else { + updateConfig({ sourceDetailConfig: undefined }); + } + }} + /> + +
+

+ 모달에서 전달받은 마스터 데이터의 디테일 행을 자동으로 조회하여 리피터에 채웁니다. +

+ + {config.sourceDetailConfig && ( +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다. + + {allTables.map((table) => ( + { + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + tableName: table.tableName, + }, + }); + }} + className="text-xs" + > + + {table.displayName} + + ))} + + + + + +
+ +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + foreignKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ + + updateConfig({ + sourceDetailConfig: { + ...config.sourceDetailConfig!, + parentKey: e.target.value, + }, + }) + } + placeholder="예: order_no" + className="h-7 text-xs" + /> +
+
+ +

+ 마스터에서 [{config.sourceDetailConfig.parentKey || "?"}] 추출 → + {" "}{config.sourceDetailConfig.tableName || "?"}.{config.sourceDetailConfig.foreignKey || "?"} 로 조회 +

+
+ )} +
+ + + {/* 기능 옵션 */}
diff --git a/frontend/contexts/MenuContext.tsx b/frontend/contexts/MenuContext.tsx index 88b15542..30661de2 100644 --- a/frontend/contexts/MenuContext.tsx +++ b/frontend/contexts/MenuContext.tsx @@ -38,7 +38,9 @@ export function MenuProvider({ children }: { children: ReactNode }) { regdate: item.REGDATE || item.regdate, company_code: item.COMPANY_CODE || item.company_code, company_name: item.COMPANY_NAME || item.company_name, - // 다국어 관련 필드 추가 + // 아이콘 필드 + menu_icon: item.MENU_ICON || item.menu_icon, + // 다국어 관련 필드 lang_key: item.LANG_KEY || item.lang_key, lang_key_desc: item.LANG_KEY_DESC || item.lang_key_desc, translated_name: item.TRANSLATED_NAME || item.translated_name, diff --git a/frontend/hooks/pop/usePopAction.ts b/frontend/hooks/pop/usePopAction.ts index 267beb4e..606a2953 100644 --- a/frontend/hooks/pop/usePopAction.ts +++ b/frontend/hooks/pop/usePopAction.ts @@ -24,6 +24,7 @@ import { usePopEvent } from "./usePopEvent"; import { executePopAction } from "./executePopAction"; import type { ActionResult } from "./executePopAction"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; // ======================================== // 타입 정의 @@ -107,7 +108,7 @@ export function usePopAction(screenId: string) { const msg = ACTION_SUCCESS_MESSAGES[action.type]; if (msg) toast.success(msg); } else { - toast.error(result.error || "작업에 실패했습니다."); + showErrorToast("작업에 실패했습니다", result.error, { guidance: "잠시 후 다시 시도해 주세요." }); } // 성공 시 후속 액션 실행 diff --git a/frontend/lib/api/flow.ts b/frontend/lib/api/flow.ts index ff2a81a2..c6c69a22 100644 --- a/frontend/lib/api/flow.ts +++ b/frontend/lib/api/flow.ts @@ -451,13 +451,15 @@ export async function moveData(data: MoveDataRequest): Promise> { return moveData({ flowId, - currentStepId, - dataId, + fromStepId, + recordId: String(recordId), + toStepId, }); } @@ -559,3 +561,61 @@ export async function updateFlowStepData( }; } } + +// ============================================ +// 프로시저/함수 API +// ============================================ + +import type { ProcedureListItem, ProcedureParameterInfo } from "@/types/flowExternalDb"; + +/** + * 프로시저/함수 목록 조회 + */ +export async function getFlowProcedures( + dbSource: "internal" | "external", + connectionId?: number, + schema?: string, +): Promise> { + try { + const params = new URLSearchParams({ dbSource }); + if (connectionId) params.set("connectionId", String(connectionId)); + if (schema) params.set("schema", schema); + + const response = await fetch(`${API_BASE}/flow/procedures?${params.toString()}`, { + headers: getAuthHeaders(), + credentials: "include", + }); + + return await response.json(); + } catch (error: any) { + return { success: false, error: error.message }; + } +} + +/** + * 프로시저/함수 파라미터 조회 + */ +export async function getFlowProcedureParameters( + name: string, + dbSource: "internal" | "external", + connectionId?: number, + schema?: string, +): Promise> { + try { + const params = new URLSearchParams({ dbSource }); + if (connectionId) params.set("connectionId", String(connectionId)); + if (schema) params.set("schema", schema); + + const response = await fetch( + `${API_BASE}/flow/procedures/${encodeURIComponent(name)}/parameters?${params.toString()}`, + { + headers: getAuthHeaders(), + credentials: "include", + }, + ); + + return await response.json(); + } catch (error: any) { + return { success: false, error: error.message }; + } +} diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index 67de76ae..8611aeda 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -40,6 +40,8 @@ export interface MenuItem { TRANSLATED_NAME?: string; translated_desc?: string; TRANSLATED_DESC?: string; + menu_icon?: string; + MENU_ICON?: string; } export interface MenuFormData { @@ -52,8 +54,9 @@ export interface MenuFormData { menuType: string; status: string; companyCode: string; - langKey?: string; // 다국어 키 추가 - screenCode?: string; // 화면 코드 추가 + langKey?: string; + screenCode?: string; + menuIcon?: string; } export interface LangKey { diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index f753a240..dd820cc3 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -20,6 +20,7 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; @@ -955,7 +956,7 @@ export const ButtonPrimaryComponent: React.FC = ({ } } catch (error: any) { console.error("❌ 데이터 전달 실패:", error); - toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); + showErrorToast("데이터 전달에 실패했습니다", error, { guidance: "대상 화면 설정과 데이터를 확인해 주세요." }); } }; diff --git a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx index f655ebe3..b2c385a3 100644 --- a/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx +++ b/frontend/lib/registry/components/file-upload/FileUploadComponent.tsx @@ -3,6 +3,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { toast } from "sonner"; +import { showErrorToast } from "@/lib/utils/toastUtils"; import { uploadFiles, downloadFile, deleteFile, getComponentFiles } from "@/lib/api/file"; import { GlobalFileManager } from "@/lib/api/globalFile"; import { formatFileSize } from "@/lib/utils"; @@ -881,7 +882,7 @@ const FileUploadComponent: React.FC = ({ console.error("파일 업로드 오류:", error); setUploadStatus("error"); toast.dismiss("file-upload"); - toast.error(`파일 업로드 오류: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); + showErrorToast("파일 업로드에 실패했습니다", error, { guidance: "파일 크기와 형식을 확인하고 다시 시도해 주세요." }); } }, [safeComponentConfig, uploadedFiles, onFormDataChange, component.columnName, component.id, formData], @@ -1006,7 +1007,7 @@ const FileUploadComponent: React.FC = ({ toast.success(`${fileName} 삭제 완료`); } catch (error) { console.error("파일 삭제 오류:", error); - toast.error("파일 삭제에 실패했습니다."); + showErrorToast("파일 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); } }, [uploadedFiles, onUpdate, component.id, isRecordMode, onFormDataChange, recordTableName, recordId, columnName, getUniqueKey], diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index d57ae60b..532881b7 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -553,14 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 카테고리 라벨 변환 함수 + // 카테고리/셀렉트 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; + // select 타입 컬럼의 selectOptions에서 라벨 찾기 + if (column.selectOptions && column.selectOptions.length > 0) { + const matchedOption = column.selectOptions.find((opt) => opt.value === val); + if (matchedOption) return matchedOption.label; + } + const fieldName = column.field.replace(/^_display_/, ""); const isCategoryColumn = categoryColumns.includes(fieldName); - // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 if (categoryLabelMap[val]) return categoryLabelMap[val]; // 카테고리 컬럼이 아니면 원래 값 반환 diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx index a06c046f..6c631d83 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Component.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { toast } from "sonner"; import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { apiClient } from "@/lib/api/client"; +import { getCategoryValues } from "@/lib/api/tableCategoryValue"; export interface SplitPanelLayout2ComponentProps extends ComponentRendererProps { // 추가 props @@ -92,6 +93,9 @@ export const SplitPanelLayout2Component: React.FC(null); const [rightActiveTab, setRightActiveTab] = useState(null); + // 카테고리 코드→라벨 매핑 + const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + // 프론트엔드 그룹핑 함수 const groupData = useCallback( (data: Record[], groupingConfig: GroupingConfig, columns: ColumnConfig[]): Record[] => { @@ -185,17 +189,17 @@ export const SplitPanelLayout2Component: React.FC ({ id: value, - label: value, + label: categoryLabelMap[value] || value, count: tabConfig.showCount ? count : 0, })); console.log(`[SplitPanelLayout2] 탭 생성: ${tabs.length}개 탭 (컬럼: ${sourceColumn})`); return tabs; }, - [], + [categoryLabelMap], ); // 탭으로 필터링된 데이터 반환 @@ -1000,10 +1004,38 @@ export const SplitPanelLayout2Component: React.FC { + loadLeftData(); + if (selectedLeftItem) { + loadRightData(selectedLeftItem); + } + }, + }, + }); + window.dispatchEvent(editEvent); + console.log("[SplitPanelLayout2] 좌측 수정 모달 열기:", selectedLeftItem); break; + } case "delete": // 좌측 패널에서 삭제 (필요시 구현) @@ -1018,7 +1050,7 @@ export const SplitPanelLayout2Component: React.FC { + if (isDesignMode) return; + + const loadCategoryLabels = async () => { + const allColumns = new Set(); + const tableName = config.leftPanel?.tableName || config.rightPanel?.tableName; + if (!tableName) return; + + // 좌우 패널의 표시 컬럼에서 카테고리 후보 수집 + for (const col of config.leftPanel?.displayColumns || []) { + allColumns.add(col.name); + } + for (const col of config.rightPanel?.displayColumns || []) { + allColumns.add(col.name); + } + // 탭 소스 컬럼도 추가 + if (config.rightPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.rightPanel.tabConfig.tabSourceColumn); + } + if (config.leftPanel?.tabConfig?.tabSourceColumn) { + allColumns.add(config.leftPanel.tabConfig.tabSourceColumn); + } + + const labelMap: Record = {}; + + for (const columnName of allColumns) { + try { + const result = await getCategoryValues(tableName, columnName); + if (result.success && Array.isArray(result.data) && result.data.length > 0) { + for (const item of result.data) { + if (item.valueCode && item.valueLabel) { + labelMap[item.valueCode] = item.valueLabel; + } + } + } + } catch { + // 카테고리가 아닌 컬럼은 무시 + } + } + + if (Object.keys(labelMap).length > 0) { + setCategoryLabelMap(labelMap); + } + }; + + loadCategoryLabels(); + }, [isDesignMode, config.leftPanel?.tableName, config.rightPanel?.tableName, config.leftPanel?.displayColumns, config.rightPanel?.displayColumns, config.rightPanel?.tabConfig?.tabSourceColumn, config.leftPanel?.tabConfig?.tabSourceColumn]); + // 컴포넌트 언마운트 시 DataProvider 해제 useEffect(() => { return () => { @@ -1250,6 +1331,23 @@ export const SplitPanelLayout2Component: React.FC { + if (value === null || value === undefined) return ""; + const strVal = String(value); + if (categoryLabelMap[strVal]) return categoryLabelMap[strVal]; + // 콤마 구분 다중 값 처리 + if (strVal.includes(",")) { + const codes = strVal.split(",").map((c) => c.trim()).filter(Boolean); + const labels = codes.map((code) => categoryLabelMap[code] || code); + return labels.join(", "); + } + return strVal; + }, + [categoryLabelMap], + ); + // 컬럼 값 가져오기 (sourceTable 및 엔티티 참조 고려) const getColumnValue = useCallback( (item: any, col: ColumnConfig): any => { @@ -1547,7 +1645,7 @@ export const SplitPanelLayout2Component: React.FC { const value = item[col.name]; if (value === null || value === undefined) return "-"; @@ -1558,7 +1656,7 @@ export const SplitPanelLayout2Component: React.FC {value.map((v, vIdx) => ( - {formatValue(v, col.format)} + {resolveCategoryLabel(v) || formatValue(v, col.format)} ))}
@@ -1567,14 +1665,17 @@ export const SplitPanelLayout2Component: React.FC - {formatValue(value, col.format)} + {label !== String(value) ? label : formatValue(value, col.format)}
); } - // 기본 텍스트 + // 카테고리 라벨 변환 시도 후 기본 텍스트 + const label = resolveCategoryLabel(value); + if (label !== String(value)) return label; return formatValue(value, col.format); }; @@ -1821,9 +1922,12 @@ export const SplitPanelLayout2Component: React.FC )} - {displayColumns.map((col, colIdx) => ( - {formatValue(getColumnValue(item, col), col.format)} - ))} + {displayColumns.map((col, colIdx) => { + const rawVal = getColumnValue(item, col); + const resolved = resolveCategoryLabel(rawVal); + const display = resolved !== String(rawVal ?? "") ? resolved : formatValue(rawVal, col.format); + return {display || "-"}; + })} {(config.rightPanel?.showEditButton || config.rightPanel?.showDeleteButton) && (
@@ -2133,7 +2237,12 @@ export const SplitPanelLayout2Component: React.FC 0 && (
- {config.leftPanel.actionButtons.map((btn, idx) => ( + {config.leftPanel.actionButtons + .filter((btn) => { + if (btn.showCondition === "selected") return !!selectedLeftItem; + return true; + }) + .map((btn, idx) => (