"use client"; import React from "react"; import { toast } from "sonner"; import { screenApi } from "@/lib/api/screen"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor"; import type { ExtendedControlContext } from "@/types/control-management"; /** * 버튼 액션 타입 정의 */ export type ButtonActionType = | "save" // 저장 | "delete" // 삭제 | "edit" // 편집 | "navigate" // 페이지 이동 | "modal" // 모달 열기 | "control" // 제어 흐름 | "view_table_history" // 테이블 이력 보기 | "excel_download" // 엑셀 다운로드 | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge"; // 코드 병합 /** * 버튼 액션 설정 */ export interface ButtonActionConfig { type: ButtonActionType; // 저장/제출 관련 saveEndpoint?: string; validateForm?: boolean; // 네비게이션 관련 targetUrl?: string; targetScreenId?: number; // 모달/팝업 관련 modalTitle?: string; modalDescription?: string; modalSize?: "sm" | "md" | "lg" | "xl"; popupWidth?: number; popupHeight?: number; // 확인 메시지 confirmMessage?: string; successMessage?: string; errorMessage?: string; // 제어관리 관련 enableDataflowControl?: boolean; dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용) dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍 // 테이블 이력 보기 관련 historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정) historyRecordIdField?: string; // PK 필드명 (기본: "id") historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스 historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항) historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name) // 엑셀 다운로드 관련 excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx) excelSheetName?: string; // 시트명 (기본: "Sheet1") excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true) // 엑셀 업로드 관련 excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드 excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼 // 바코드 스캔 관련 barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all") barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부 // 코드 병합 관련 mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) } /** * 버튼 액션 실행 컨텍스트 */ export interface ButtonActionContext { formData: Record; originalData?: Record; // 부분 업데이트용 원본 데이터 screenId?: number; tableName?: string; userId?: string; // 🆕 현재 로그인한 사용자 ID userName?: string; // 🆕 현재 로그인한 사용자 이름 companyCode?: string; // 🆕 현재 사용자의 회사 코드 onFormDataChange?: (fieldName: string, value: any) => void; onClose?: () => void; onRefresh?: () => void; onFlowRefresh?: () => void; // 플로우 새로고침 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; // 제어 실행을 위한 추가 정보 buttonId?: string; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; // 정렬 컬럼명 sortOrder?: "asc" | "desc"; // 정렬 방향 columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서) tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨) } /** * 버튼 액션 실행기 */ export class ButtonActionExecutor { /** * 액션 실행 */ static async executeAction(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { // 확인 로직은 컴포넌트에서 처리하므로 여기서는 제거 switch (config.type) { case "save": return await this.handleSave(config, context); case "delete": return await this.handleDelete(config, context); case "navigate": return this.handleNavigate(config, context); case "modal": return await this.handleModal(config, context); case "edit": return await this.handleEdit(config, context); case "control": return this.handleControl(config, context); case "view_table_history": return this.handleViewTableHistory(config, context); case "excel_download": return await this.handleExcelDownload(config, context); case "excel_upload": return await this.handleExcelUpload(config, context); case "barcode_scan": return await this.handleBarcodeScan(config, context); case "code_merge": return await this.handleCodeMerge(config, context); default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; } } catch (error) { console.error("버튼 액션 실행 오류:", error); toast.error(config.errorMessage || "작업 중 오류가 발생했습니다."); return false; } } /** * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { const { formData, originalData, tableName, screenId } = context; // 폼 유효성 검사 if (config.validateForm) { const validation = this.validateFormData(formData); if (!validation.isValid) { toast.error(`입력값을 확인해주세요: ${validation.errors.join(", ")}`); return false; } } try { // API 엔드포인트가 지정된 경우 if (config.saveEndpoint) { const response = await fetch(config.saveEndpoint, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(formData), }); if (!response.ok) { throw new Error(`저장 실패: ${response.statusText}`); } } else if (tableName && screenId) { // DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단 const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName); if (!primaryKeyResult.success) { throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다."); } const primaryKeys = primaryKeyResult.data || []; const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys); // 단순히 기본키 값 존재 여부로 판단 (임시) // TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요 const isUpdate = false; // 현재는 항상 INSERT로 처리 let saveResult; if (isUpdate) { // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) console.log("🔄 UPDATE 모드로 저장:", { primaryKeyValue, formData, originalData, hasOriginalData: !!originalData, }); if (originalData) { // 부분 업데이트: 변경된 필드만 업데이트 console.log("📝 부분 업데이트 실행 (변경된 필드만)"); saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); } else { // 전체 업데이트 (기존 방식) console.log("📝 전체 업데이트 실행 (모든 필드)"); saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, { tableName, data: formData, }); } } else { // INSERT 처리 // 🆕 자동으로 작성자 정보 추가 if (!context.userId) { throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); } const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; console.log("👤 [buttonActions] 사용자 정보:", { userId: context.userId, userName: context.userName, companyCode: context.companyCode, // ✅ 회사 코드 formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값 formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값 defaultWriterValue: writerValue, companyCodeValue, // ✅ 최종 회사 코드 값 }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) console.log("🔍 채번 규칙 할당 체크 시작"); console.log("📦 현재 formData:", JSON.stringify(formData, null, 2)); const fieldsWithNumbering: Record = {}; // formData에서 채번 규칙이 설정된 필드 찾기 for (const [key, value] of Object.entries(formData)) { if (key.endsWith("_numberingRuleId") && value) { const fieldName = key.replace("_numberingRuleId", ""); fieldsWithNumbering[fieldName] = value as string; console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`); } } console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering); console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length); // 각 필드에 대해 실제 코드 할당 for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); const response = await allocateNumberingCode(ruleId); console.log(`📡 API 응답 (${fieldName}):`, response); if (response.success && response.data) { const generatedCode = response.data.generatedCode; formData[fieldName] = generatedCode; console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`); } else { console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error); toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`); } } catch (error) { console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error); toast.error(`${fieldName} 채번 규칙 할당 오류`); } } console.log("✅ 채번 규칙 할당 완료"); console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); const dataWithUserInfo = { ...formData, writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId created_by: writerValue, // created_by는 항상 로그인한 사람 updated_by: writerValue, // updated_by는 항상 로그인한 사람 company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; // _numberingRuleId 필드 제거 (실제 저장하지 않음) for (const key of Object.keys(dataWithUserInfo)) { if (key.endsWith("_numberingRuleId")) { delete dataWithUserInfo[key]; } } saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, data: dataWithUserInfo, }); } if (!saveResult.success) { throw new Error(saveResult.message || "저장에 실패했습니다."); } // 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우) if (config.enableDataflowControl && config.dataflowConfig) { console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig); await this.executeAfterSaveControl(config, context); } } else { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); context.onFlowRefresh?.(); // 저장 성공 후 EditModal 닫기 이벤트 발생 window.dispatchEvent(new CustomEvent("closeEditModal")); return true; } catch (error) { console.error("저장 오류:", error); throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함 } } /** * DB에서 조회한 실제 기본키로 formData에서 값 추출 * @param formData 폼 데이터 * @param primaryKeys DB에서 조회한 실제 기본키 컬럼명 배열 * @returns 기본키 값 (복합키의 경우 첫 번째 키 값) */ private static extractPrimaryKeyValueFromDB(formData: Record, primaryKeys: string[]): any { if (!primaryKeys || primaryKeys.length === 0) { return null; } // 첫 번째 기본키 컬럼의 값을 사용 (복합키의 경우) const primaryKeyColumn = primaryKeys[0]; if (formData.hasOwnProperty(primaryKeyColumn)) { const value = formData[primaryKeyColumn]; console.log(`🔑 DB 기본키 발견: ${primaryKeyColumn} = ${value}`); // 복합키인 경우 로그 출력 if (primaryKeys.length > 1) { console.log(`🔗 복합 기본키 감지:`, primaryKeys); console.log(`📍 첫 번째 키 (${primaryKeyColumn}) 값을 사용: ${value}`); } return value; } // 기본키 컬럼이 formData에 없는 경우 return null; } /** * @deprecated DB 기반 조회로 대체됨. extractPrimaryKeyValueFromDB 사용 권장 * formData에서 기본 키값 추출 (추측 기반) */ private static extractPrimaryKeyValue(formData: Record): any { // 일반적인 기본 키 필드명들 (우선순위 순) const commonPrimaryKeys = [ "id", "ID", // 가장 일반적 "objid", "OBJID", // 이 프로젝트에서 자주 사용 "pk", "PK", // Primary Key 줄임말 "_id", // MongoDB 스타일 "uuid", "UUID", // UUID 방식 "key", "KEY", // 기타 ]; // 우선순위에 따라 기본 키값 찾기 for (const keyName of commonPrimaryKeys) { if (formData.hasOwnProperty(keyName)) { const value = formData[keyName]; console.log(`🔑 추측 기반 기본 키 발견: ${keyName} = ${value}`); return value; } } // 기본 키를 찾지 못한 경우 return null; } /** * 제출 액션 처리 */ private static async handleSubmit(config: ButtonActionConfig, context: ButtonActionContext): Promise { // 제출은 저장과 유사하지만 추가적인 처리가 있을 수 있음 return await this.handleSave(config, context); } /** * 삭제 액션 처리 */ private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise { const { formData, tableName, screenId, selectedRowsData, flowSelectedData } = context; try { // 플로우 선택 데이터 우선 사용 let dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("🔍 handleDelete - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), flowSelectedDataLength: flowSelectedData?.length || 0, hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), selectedRowsDataLength: selectedRowsData?.length || 0, dataToDeleteLength: dataToDelete?.length || 0, }); // 다중 선택된 데이터가 있는 경우 if (dataToDelete && dataToDelete.length > 0) { console.log(`다중 삭제 액션 실행: ${dataToDelete.length}개 항목`, dataToDelete); // 테이블의 기본키 조회 let primaryKeys: string[] = []; if (tableName) { try { const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(tableName); if (primaryKeysResult.success && primaryKeysResult.data) { primaryKeys = primaryKeysResult.data; console.log(`🔑 테이블 ${tableName}의 기본키:`, primaryKeys); } } catch (error) { console.warn("기본키 조회 실패, 폴백 방법 사용:", error); } } // 각 선택된 항목을 삭제 for (const rowData of dataToDelete) { let deleteId: any = null; // 1순위: 데이터베이스에서 조회한 기본키 사용 if (primaryKeys.length > 0) { const primaryKey = primaryKeys[0]; // 첫 번째 기본키 사용 deleteId = rowData[primaryKey]; console.log(`📊 기본키 ${primaryKey}로 ID 추출:`, deleteId); } // 2순위: 폴백 - 일반적인 ID 필드명들 시도 if (!deleteId) { deleteId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK || // 테이블별 기본키 패턴들 rowData.sales_no || rowData.contract_no || rowData.order_no || rowData.seq_no || rowData.code || rowData.code_id || rowData.user_id || rowData.menu_id; // _no로 끝나는 필드들 찾기 if (!deleteId) { const noField = Object.keys(rowData).find((key) => key.endsWith("_no") && rowData[key]); if (noField) deleteId = rowData[noField]; } // _id로 끝나는 필드들 찾기 if (!deleteId) { const idField = Object.keys(rowData).find((key) => key.endsWith("_id") && rowData[key]); if (idField) deleteId = rowData[idField]; } console.log(`🔍 폴백 방법으로 ID 추출:`, deleteId); } console.log("선택된 행 데이터:", rowData); console.log("최종 추출된 deleteId:", deleteId); if (deleteId) { console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId }); const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName); if (!deleteResult.success) { throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`); } } else { console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData); throw new Error( `삭제 ID를 찾을 수 없습니다. 기본키: ${primaryKeys.join(", ")}, 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`, ); } } console.log(`✅ 다중 삭제 성공: ${dataToDelete.length}개 항목`); // 데이터 소스에 따라 적절한 새로고침 호출 if (flowSelectedData && flowSelectedData.length > 0) { console.log("🔄 플로우 데이터 삭제 완료, 플로우 새로고침 호출"); context.onFlowRefresh?.(); // 플로우 새로고침 } else { console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출"); context.onRefresh?.(); // 테이블 새로고침 } return true; } // 단일 삭제 (기존 로직) if (tableName && screenId && formData.id) { console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id }); // 실제 삭제 API 호출 const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName); if (!deleteResult.success) { throw new Error(deleteResult.message || "삭제에 실패했습니다."); } console.log("✅ 단일 삭제 성공:", deleteResult); } else { throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)"); } context.onRefresh?.(); return true; } catch (error) { console.error("삭제 오류:", error); throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함 } } /** * 초기화 액션 처리 */ private static handleReset(config: ButtonActionConfig, context: ButtonActionContext): boolean { const { formData, onFormDataChange } = context; // 폼 데이터 초기화 - 각 필드를 개별적으로 초기화 if (onFormDataChange && formData) { Object.keys(formData).forEach((key) => { onFormDataChange(key, ""); }); } toast.success(config.successMessage || "초기화되었습니다."); return true; } /** * 취소 액션 처리 */ private static handleCancel(config: ButtonActionConfig, context: ButtonActionContext): boolean { const { onClose } = context; onClose?.(); return true; } /** * 네비게이션 액션 처리 */ private static handleNavigate(config: ButtonActionConfig, context: ButtonActionContext): boolean { let targetUrl = config.targetUrl; // 화면 ID가 지정된 경우 URL 생성 if (config.targetScreenId) { targetUrl = `/screens/${config.targetScreenId}`; } if (targetUrl) { window.location.href = targetUrl; return true; } toast.error("이동할 페이지가 지정되지 않았습니다."); return false; } /** * 모달 액션 처리 */ private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise { // 모달 열기 로직 console.log("모달 열기:", { title: config.modalTitle, size: config.modalSize, targetScreenId: config.targetScreenId, }); if (config.targetScreenId) { // 1. config에 modalDescription이 있으면 우선 사용 let description = config.modalDescription || ""; // 2. config에 없으면 화면 정보에서 가져오기 if (!description) { try { const screenInfo = await screenApi.getScreen(config.targetScreenId); description = screenInfo?.description || ""; } catch (error) { console.warn("화면 설명을 가져오지 못했습니다:", error); } } // 전역 모달 상태 업데이트를 위한 이벤트 발생 const modalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, title: config.modalTitle || "화면", description: description, size: config.modalSize || "md", }, }); window.dispatchEvent(modalEvent); // 모달 열기는 조용히 처리 (토스트 불필요) } else { console.error("모달로 열 화면이 지정되지 않았습니다."); return false; } return true; } /** * 새 창 액션 처리 */ private static handleNewWindow(config: ButtonActionConfig, context: ButtonActionContext): boolean { let targetUrl = config.targetUrl; // 화면 ID가 지정된 경우 URL 생성 if (config.targetScreenId) { targetUrl = `/screens/${config.targetScreenId}`; } if (targetUrl) { const windowFeatures = `width=${config.popupWidth || 800},height=${config.popupHeight || 600},scrollbars=yes,resizable=yes`; window.open(targetUrl, "_blank", windowFeatures); return true; } toast.error("열 페이지가 지정되지 않았습니다."); return false; } /** * 팝업 액션 처리 */ private static handlePopup(config: ButtonActionConfig, context: ButtonActionContext): boolean { // 팝업은 새 창과 유사하지만 더 작은 크기 return this.handleNewWindow( { ...config, popupWidth: config.popupWidth || 600, popupHeight: config.popupHeight || 400, }, context, ); } /** * 검색 액션 처리 */ private static handleSearch(config: ButtonActionConfig, context: ButtonActionContext): boolean { const { formData, onRefresh } = context; console.log("검색 실행:", formData); // 검색 조건 검증 const hasSearchCriteria = Object.values(formData).some( (value) => value !== null && value !== undefined && value !== "", ); if (!hasSearchCriteria) { toast.warning("검색 조건을 입력해주세요."); return false; } // 검색 실행 (데이터 새로고침) onRefresh?.(); // 검색 조건을 URL 파라미터로 추가 (선택사항) const searchParams = new URLSearchParams(); Object.entries(formData).forEach(([key, value]) => { if (value !== null && value !== undefined && value !== "") { searchParams.set(key, String(value)); } }); // URL 업데이트 (히스토리에 추가하지 않음) if (searchParams.toString()) { const newUrl = `${window.location.pathname}?${searchParams.toString()}`; window.history.replaceState({}, "", newUrl); } toast.success(config.successMessage || "검색을 실행했습니다."); return true; } /** * 추가 액션 처리 */ private static handleAdd(config: ButtonActionConfig, context: ButtonActionContext): boolean { console.log("추가 액션 실행:", context); // 추가 로직 구현 (예: 새 레코드 생성 폼 열기) return true; } /** * 편집 액션 처리 */ private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("🔍 handleEdit - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), flowSelectedDataLength: flowSelectedData?.length || 0, hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), selectedRowsDataLength: selectedRowsData?.length || 0, dataToEditLength: dataToEdit?.length || 0, }); // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { toast.error("수정할 항목을 선택해주세요."); return false; } // 편집 화면이 설정되지 않은 경우 if (!config.targetScreenId) { toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요."); return false; } console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, { dataToEdit, targetScreenId: config.targetScreenId, editMode: config.editMode, }); if (dataToEdit.length === 1) { // 단일 항목 편집 const rowData = dataToEdit[0]; console.log("📝 단일 항목 편집:", rowData); await this.openEditForm(config, rowData, context); } else { // 다중 항목 편집 - 현재는 단일 편집만 지원 toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요."); return false; // TODO: 향후 다중 편집 지원 // console.log("📝 다중 항목 편집:", selectedRowsData); // this.openBulkEditForm(config, selectedRowsData, context); } return true; } /** * 편집 폼 열기 (단일 항목) */ private static async openEditForm( config: ButtonActionConfig, rowData: any, context: ButtonActionContext, ): Promise { const editMode = config.editMode || "modal"; switch (editMode) { case "modal": // 모달로 편집 폼 열기 await this.openEditModal(config, rowData, context); break; case "navigate": // 새 페이지로 이동 this.navigateToEditScreen(config, rowData, context); break; case "inline": // 현재 화면에서 인라인 편집 (향후 구현) toast.info("인라인 편집 기능은 향후 지원 예정입니다."); break; default: // 기본값: 모달 this.openEditModal(config, rowData, context); } } /** * 편집 모달 열기 */ private static async openEditModal( config: ButtonActionConfig, rowData: any, context: ButtonActionContext, ): Promise { console.log("🎭 편집 모달 열기:", { targetScreenId: config.targetScreenId, modalSize: config.modalSize, rowData, }); // 1. config에 editModalDescription이 있으면 우선 사용 let description = config.editModalDescription || ""; // 2. config에 없으면 화면 정보에서 가져오기 if (!description && config.targetScreenId) { try { const screenInfo = await screenApi.getScreen(config.targetScreenId); description = screenInfo?.description || ""; } catch (error) { console.warn("화면 설명을 가져오지 못했습니다:", error); } } // 모달 열기 이벤트 발생 const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, title: config.editModalTitle || "데이터 수정", description: description, modalSize: config.modalSize || "lg", editData: rowData, onSave: () => { // 저장 후 테이블 새로고침 console.log("💾 편집 저장 완료 - 테이블 새로고침"); context.onRefresh?.(); }, }, }); window.dispatchEvent(modalEvent); // 편집 모달 열기는 조용히 처리 (토스트 없음) } /** * 편집 화면으로 이동 */ private static navigateToEditScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { const rowId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK; if (!rowId) { toast.error("수정할 항목의 ID를 찾을 수 없습니다."); return; } const editUrl = `/screens/${config.targetScreenId}?mode=edit&id=${rowId}`; console.log("🔄 편집 화면으로 이동:", editUrl); window.location.href = editUrl; } /** * 닫기 액션 처리 */ private static handleClose(config: ButtonActionConfig, context: ButtonActionContext): boolean { console.log("닫기 액션 실행:", context); context.onClose?.(); return true; } /** * 제어 전용 액션 처리 (조건 체크만 수행) */ private static async handleControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { console.log("🎯 ButtonActionExecutor.handleControl 실행:", { formData: context.formData, selectedRows: context.selectedRows, selectedRowsData: context.selectedRowsData, flowSelectedData: context.flowSelectedData, flowSelectedStepId: context.flowSelectedStepId, config, }); // 🔥 제어 조건이 설정되어 있는지 확인 console.log("🔍 제어관리 활성화 상태 확인:", { enableDataflowControl: config.enableDataflowControl, hasDataflowConfig: !!config.dataflowConfig, dataflowConfig: config.dataflowConfig, fullConfig: config, }); if (!config.dataflowConfig || !config.enableDataflowControl) { console.warn("⚠️ 제어관리가 비활성화되어 있습니다:", { enableDataflowControl: config.enableDataflowControl, hasDataflowConfig: !!config.dataflowConfig, }); toast.warning( "제어관리가 활성화되지 않았습니다. 버튼 설정에서 '제어관리 활성화'를 체크하고 조건을 설정해주세요.", ); return false; } try { // 🔥 확장된 제어 컨텍스트 생성 // 자동으로 적절한 controlDataSource 결정 let controlDataSource = config.dataflowConfig.controlDataSource; if (!controlDataSource) { // 설정이 없으면 자동 판단 (우선순위 순서대로) if (context.flowSelectedData && context.flowSelectedData.length > 0) { controlDataSource = "flow-selection"; console.log("🔄 자동 판단: flow-selection 모드 사용"); } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { controlDataSource = "table-selection"; console.log("🔄 자동 판단: table-selection 모드 사용"); } else if (context.formData && Object.keys(context.formData).length > 0) { controlDataSource = "form"; console.log("🔄 자동 판단: form 모드 사용"); } else { controlDataSource = "form"; // 기본값 console.log("🔄 기본값: form 모드 사용"); } } console.log("📊 데이터 소스 모드:", { controlDataSource, hasFormData: !!(context.formData && Object.keys(context.formData).length > 0), hasTableSelection: !!(context.selectedRowsData && context.selectedRowsData.length > 0), hasFlowSelection: !!(context.flowSelectedData && context.flowSelectedData.length > 0), }); const extendedContext: ExtendedControlContext = { formData: context.formData || {}, selectedRows: context.selectedRows || [], selectedRowsData: context.selectedRowsData || [], flowSelectedData: context.flowSelectedData || [], flowSelectedStepId: context.flowSelectedStepId, controlDataSource, }; console.log("🔍 제어 조건 검증 시작:", { dataflowConfig: config.dataflowConfig, extendedContext, }); // 🔥 새로운 버튼 액션 실행 시스템 사용 if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) { console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig); const { flowId, executionTiming } = config.dataflowConfig.flowConfig; if (!flowId) { console.error("❌ 플로우 ID가 없습니다"); toast.error("플로우가 설정되지 않았습니다."); return false; } try { // 노드 플로우 실행 API 호출 (API 클라이언트 사용) const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비: controlDataSource 설정 기반 let sourceData: any = null; let dataSourceType: string = controlDataSource || "none"; console.log("🔍 데이터 소스 결정:", { controlDataSource, hasFlowSelectedData: !!(context.flowSelectedData && context.flowSelectedData.length > 0), hasSelectedRowsData: !!(context.selectedRowsData && context.selectedRowsData.length > 0), hasFormData: !!(context.formData && Object.keys(context.formData).length > 0), }); // controlDataSource 설정에 따라 데이터 선택 switch (controlDataSource) { case "flow-selection": if (context.flowSelectedData && context.flowSelectedData.length > 0) { sourceData = context.flowSelectedData; console.log("🌊 플로우 선택 데이터 사용:", { stepId: context.flowSelectedStepId, dataCount: sourceData.length, sourceData, }); } else { console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다."); } break; case "table-selection": if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData = context.selectedRowsData; console.log("📊 테이블 선택 데이터 사용:", { dataCount: sourceData.length, sourceData, }); } else { console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다."); } break; case "form": if (context.formData && Object.keys(context.formData).length > 0) { sourceData = [context.formData]; console.log("📝 폼 데이터 사용:", sourceData); } else { console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다."); } break; case "both": // 폼 + 테이블 선택 sourceData = []; if (context.formData && Object.keys(context.formData).length > 0) { sourceData.push(context.formData); } if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData.push(...context.selectedRowsData); } console.log("🔀 폼 + 테이블 선택 데이터 사용:", { dataCount: sourceData.length, sourceData, }); break; default: // 자동 판단 (설정이 없는 경우) if (context.flowSelectedData && context.flowSelectedData.length > 0) { sourceData = context.flowSelectedData; dataSourceType = "flow-selection"; console.log("🌊 [자동] 플로우 선택 데이터 사용"); } else if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData = context.selectedRowsData; dataSourceType = "table-selection"; console.log("📊 [자동] 테이블 선택 데이터 사용"); } else if (context.formData && Object.keys(context.formData).length > 0) { sourceData = [context.formData]; dataSourceType = "form"; console.log("📝 [자동] 폼 데이터 사용"); } break; } console.log("📦 최종 전달 데이터:", { dataSourceType, sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0, sourceData, }); const result = await executeNodeFlow(flowId, { dataSourceType, sourceData, context, }); if (result.success) { console.log("✅ 노드 플로우 실행 완료:", result); toast.success("플로우 실행이 완료되었습니다."); // 플로우 새로고침 (플로우 위젯용) if (context.onFlowRefresh) { console.log("🔄 플로우 새로고침 호출"); context.onFlowRefresh(); } // 테이블 새로고침 (일반 테이블용) if (context.onRefresh) { console.log("🔄 테이블 새로고침 호출"); context.onRefresh(); } return true; } else { console.error("❌ 노드 플로우 실행 실패:", result); toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다."); return false; } } catch (error) { console.error("❌ 노드 플로우 실행 오류:", error); toast.error("플로우 실행 중 오류가 발생했습니다."); return false; } } else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) { console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig); // 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합 let mergedFormData = { ...context.formData } || {}; if ( controlDataSource === "table-selection" && context.selectedRowsData && context.selectedRowsData.length > 0 ) { // 선택된 첫 번째 행의 데이터를 formData에 병합 const selectedRowData = context.selectedRowsData[0]; mergedFormData = { ...mergedFormData, ...selectedRowData }; console.log("🔄 선택된 행 데이터를 formData에 병합:", { originalFormData: context.formData, selectedRowData, mergedFormData, }); } // 새로운 ImprovedButtonActionExecutor 사용 const buttonConfig = { actionType: config.type, dataflowConfig: config.dataflowConfig, enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화 }; const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, { buttonId: context.buttonId || "unknown", screenId: context.screenId || "unknown", userId: context.userId || "unknown", companyCode: context.companyCode || "*", startTime: Date.now(), contextData: context, }); if (executionResult.success) { console.log("✅ 관계 실행 완료:", executionResult); toast.success(config.successMessage || "관계 실행이 완료되었습니다."); // 새로고침이 필요한 경우 if (context.onRefresh) { context.onRefresh(); } return true; } else { console.error("❌ 관계 실행 실패:", executionResult); toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다."); return false; } } else { // 제어 없음 - 성공 처리 console.log("⚡ 제어 없음 - 버튼 액션만 실행"); // 새로고침이 필요한 경우 if (context.onRefresh) { context.onRefresh(); } return true; } } catch (error) { console.error("제어 조건 검증 중 오류:", error); toast.error("제어 조건 검증 중 오류가 발생했습니다."); return false; } } /** * 저장 후 제어 실행 (After Timing) */ private static async executeAfterSaveControl( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { console.log("🎯 저장 후 제어 실행:", { enableDataflowControl: config.enableDataflowControl, dataflowConfig: config.dataflowConfig, dataflowTiming: config.dataflowTiming, }); // dataflowTiming이 'after'가 아니면 실행하지 않음 if (config.dataflowTiming && config.dataflowTiming !== "after") { console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming); return; } // 제어 데이터 소스 결정 let controlDataSource = config.dataflowConfig?.controlDataSource; if (!controlDataSource) { controlDataSource = "form"; // 저장 후에는 기본적으로 form 데이터 사용 } const extendedContext: ExtendedControlContext = { formData: context.formData || {}, selectedRows: context.selectedRows || [], selectedRowsData: context.selectedRowsData || [], controlDataSource, }; // 관계 기반 제어 실행 if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) { console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig); const buttonConfig = { actionType: config.type, dataflowConfig: config.dataflowConfig, enableDataflowControl: true, }; const executionResult = await ImprovedButtonActionExecutor.executeButtonAction( buttonConfig, context.formData || {}, { buttonId: context.buttonId || "unknown", screenId: context.screenId || "unknown", userId: context.userId || "unknown", companyCode: context.companyCode || "*", startTime: Date.now(), contextData: context, }, ); if (executionResult.success) { console.log("✅ 저장 후 제어 실행 완료:", executionResult); // 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음 } else { console.error("❌ 저장 후 제어 실행 실패:", executionResult); toast.error("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } } } /** * 관계도에서 가져온 액션들을 실행 */ private static async executeRelationshipActions(actions: any[], context: ButtonActionContext): Promise { console.log("🚀 관계도 액션 실행 시작:", actions); for (let i = 0; i < actions.length; i++) { const action = actions[i]; try { console.log(`🔄 액션 ${i + 1}/${actions.length} 실행:`, action); const actionType = action.actionType || action.type; // actionType 우선, type 폴백 switch (actionType) { case "save": await this.executeActionSave(action, context); break; case "update": await this.executeActionUpdate(action, context); break; case "delete": await this.executeActionDelete(action, context); break; case "insert": await this.executeActionInsert(action, context); break; default: console.warn(`❌ 지원되지 않는 액션 타입 (${i + 1}/${actions.length}):`, { actionType, actionName: action.name, fullAction: action, }); // 지원되지 않는 액션은 오류로 처리하여 중단 throw new Error(`지원되지 않는 액션 타입: ${actionType}`); } console.log(`✅ 액션 ${i + 1}/${actions.length} 완료:`, action.name); // 성공 토스트 (개별 액션별) toast.success(`${action.name || `액션 ${i + 1}`} 완료`); } catch (error) { const actionType = action.actionType || action.type; console.error(`❌ 액션 ${i + 1}/${actions.length} 실행 실패:`, action.name, error); // 실패 토스트 toast.error( `${action.name || `액션 ${i + 1}`} 실행 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`, ); // 🚨 순차 실행 중단: 하나라도 실패하면 전체 중단 throw new Error( `액션 ${i + 1}(${action.name})에서 실패하여 제어 프로세스를 중단합니다: ${error instanceof Error ? error.message : error}`, ); } } console.log("🎉 모든 액션 실행 완료!"); toast.success(`총 ${actions.length}개 액션이 모두 성공적으로 완료되었습니다.`); } /** * 저장 액션 실행 */ private static async executeActionSave(action: any, context: ButtonActionContext): Promise { console.log("💾 저장 액션 실행:", action); console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2)); // 🎯 필드 매핑 정보 사용하여 저장 데이터 구성 let saveData: Record = {}; // 액션에 필드 매핑 정보가 있는지 확인 if (action.fieldMappings && Array.isArray(action.fieldMappings)) { console.log("📋 필드 매핑 정보 발견:", action.fieldMappings); // 필드 매핑에 따라 데이터 구성 action.fieldMappings.forEach((mapping: any) => { const { sourceField, targetField, defaultValue, valueType } = mapping; let value: any; // 값 소스에 따라 데이터 가져오기 if (valueType === "form" && context.formData && sourceField) { value = context.formData[sourceField]; } else if (valueType === "selected" && context.selectedRowsData?.[0] && sourceField) { value = context.selectedRowsData[0][sourceField]; } else if (valueType === "default" || !sourceField) { value = defaultValue; } // 타겟 필드에 값 설정 if (targetField && value !== undefined) { saveData[targetField] = value; console.log(`📝 필드 매핑: ${sourceField || "default"} -> ${targetField} = ${value}`); } }); } else { console.log("⚠️ 필드 매핑 정보가 없음, 기본 데이터 사용"); // 폴백: 기존 방식 saveData = { ...context.formData, ...context.selectedRowsData?.[0], // 선택된 데이터도 포함 }; } console.log("📊 최종 저장할 데이터:", saveData); try { // 🔥 실제 저장 API 호출 if (!context.tableName) { throw new Error("테이블명이 설정되지 않았습니다."); } const result = await DynamicFormApi.saveFormData({ screenId: 0, // 임시값 tableName: context.tableName, data: saveData, }); if (result.success) { console.log("✅ 저장 성공:", result); toast.success("데이터가 저장되었습니다."); } else { throw new Error(result.message || "저장 실패"); } } catch (error) { console.error("❌ 저장 실패:", error); toast.error(`저장 실패: ${error.message}`); throw error; } } /** * 업데이트 액션 실행 */ private static async executeActionUpdate(action: any, context: ButtonActionContext): Promise { console.log("🔄 업데이트 액션 실행:", action); console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2)); // 🎯 필드 매핑 정보 사용하여 업데이트 데이터 구성 let updateData: Record = {}; // 액션에 필드 매핑 정보가 있는지 확인 if (action.fieldMappings && Array.isArray(action.fieldMappings)) { console.log("📋 필드 매핑 정보 발견:", action.fieldMappings); // 🔑 먼저 선택된 데이터의 모든 필드를 기본으로 포함 (기본키 보존) if (context.selectedRowsData?.[0]) { updateData = { ...context.selectedRowsData[0] }; console.log("🔑 선택된 데이터를 기본으로 설정 (기본키 보존):", updateData); } // 필드 매핑에 따라 데이터 구성 (덮어쓰기) action.fieldMappings.forEach((mapping: any) => { const { sourceField, targetField, defaultValue, valueType } = mapping; let value: any; // 값 소스에 따라 데이터 가져오기 if (valueType === "form" && context.formData && sourceField) { value = context.formData[sourceField]; } else if (valueType === "selected" && context.selectedRowsData?.[0] && sourceField) { value = context.selectedRowsData[0][sourceField]; } else if (valueType === "default" || !sourceField) { value = defaultValue; } // 타겟 필드에 값 설정 (덮어쓰기) if (targetField && value !== undefined) { updateData[targetField] = value; console.log(`📝 필드 매핑: ${sourceField || "default"} -> ${targetField} = ${value}`); } }); } else { console.log("⚠️ 필드 매핑 정보가 없음, 기본 데이터 사용"); // 폴백: 기존 방식 updateData = { ...context.formData, ...context.selectedRowsData?.[0], }; } console.log("📊 최종 업데이트할 데이터:", updateData); try { // 🔥 실제 업데이트 API 호출 if (!context.tableName) { throw new Error("테이블명이 설정되지 않았습니다."); } // 먼저 ID 찾기 const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(context.tableName); let updateId: string | undefined; if (primaryKeysResult.success && primaryKeysResult.data && primaryKeysResult.data.length > 0) { updateId = updateData[primaryKeysResult.data[0]]; } if (!updateId) { // 폴백: 일반적인 ID 필드들 확인 const commonIdFields = ["id", "objid", "pk", "sales_no", "contract_no"]; for (const field of commonIdFields) { if (updateData[field]) { updateId = updateData[field]; break; } } } if (!updateId) { throw new Error("업데이트할 항목의 ID를 찾을 수 없습니다."); } const result = await DynamicFormApi.updateFormData(updateId, { tableName: context.tableName, data: updateData, }); if (result.success) { console.log("✅ 업데이트 성공:", result); toast.success("데이터가 업데이트되었습니다."); } else { throw new Error(result.message || "업데이트 실패"); } } catch (error) { console.error("❌ 업데이트 실패:", error); toast.error(`업데이트 실패: ${error.message}`); throw error; } } /** * 삭제 액션 실행 */ private static async executeActionDelete(action: any, context: ButtonActionContext): Promise { console.log("🗑️ 삭제 액션 실행:", action); // 실제 삭제 로직 (기존 handleDelete와 유사) if (!context.selectedRowsData || context.selectedRowsData.length === 0) { throw new Error("삭제할 항목을 선택해주세요."); } const deleteData = context.selectedRowsData[0]; console.log("삭제할 데이터:", deleteData); try { // 🔥 실제 삭제 API 호출 if (!context.tableName) { throw new Error("테이블명이 설정되지 않았습니다."); } // 기존 handleDelete와 동일한 로직으로 ID 찾기 const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(context.tableName); let deleteId: string | undefined; if (primaryKeysResult.success && primaryKeysResult.data && primaryKeysResult.data.length > 0) { deleteId = deleteData[primaryKeysResult.data[0]]; } if (!deleteId) { // 폴백: 일반적인 ID 필드들 확인 const commonIdFields = ["id", "objid", "pk", "sales_no", "contract_no"]; for (const field of commonIdFields) { if (deleteData[field]) { deleteId = deleteData[field]; break; } } } if (!deleteId) { throw new Error("삭제할 항목의 ID를 찾을 수 없습니다."); } const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName); if (result.success) { console.log("✅ 삭제 성공:", result); toast.success("데이터가 삭제되었습니다."); } else { throw new Error(result.message || "삭제 실패"); } } catch (error) { console.error("❌ 삭제 실패:", error); toast.error(`삭제 실패: ${error.message}`); throw error; } } /** * 삽입 액션 실행 (체크박스 선택된 데이터를 필드매핑에 따라 새 테이블에 삽입) */ private static async executeActionInsert(action: any, context: ButtonActionContext): Promise { console.log("➕ 삽입 액션 실행:", action); let insertData: Record = {}; // 액션에 필드 매핑 정보가 있는지 확인 if (action.fieldMappings && Array.isArray(action.fieldMappings)) { console.log("📋 삽입 액션 - 필드 매핑 정보:", action.fieldMappings); // 🎯 체크박스로 선택된 데이터가 있는지 확인 if (!context.selectedRowsData || context.selectedRowsData.length === 0) { throw new Error("삽입할 소스 데이터를 선택해주세요. (테이블에서 체크박스 선택 필요)"); } const sourceData = context.selectedRowsData[0]; // 첫 번째 선택된 데이터 사용 console.log("🎯 삽입 소스 데이터 (체크박스 선택):", sourceData); console.log("🔍 소스 데이터 사용 가능한 키들:", Object.keys(sourceData)); // 필드 매핑에 따라 데이터 구성 action.fieldMappings.forEach((mapping: any) => { const { sourceField, targetField, defaultValue } = mapping; // valueType이 없으면 기본값을 "selection"으로 설정 const valueType = mapping.valueType || "selection"; let value: any; console.log(`🔍 매핑 처리 중: ${sourceField} → ${targetField} (valueType: ${valueType})`); // 값 소스에 따라 데이터 가져오기 if (valueType === "form" && context.formData && sourceField) { // 폼 데이터에서 가져오기 value = context.formData[sourceField]; console.log(`📝 폼에서 매핑: ${sourceField} → ${targetField} = ${value}`); } else if (valueType === "selection" && sourceField) { // 선택된 테이블 데이터에서 가져오기 (다양한 필드명 시도) value = sourceData[sourceField] || sourceData[sourceField + "_name"] || // 조인된 필드 (_name 접미사) sourceData[sourceField + "Name"]; // 카멜케이스 console.log(`📊 테이블에서 매핑: ${sourceField} → ${targetField} = ${value} (소스필드: ${sourceField})`); } else if (valueType === "default" || (defaultValue !== undefined && defaultValue !== "")) { // 기본값 사용 (valueType이 "default"이거나 defaultValue가 있을 때) value = defaultValue; console.log(`🔧 기본값 매핑: ${targetField} = ${value}`); } else { console.warn(`⚠️ 매핑 실패: ${sourceField} → ${targetField} (값을 찾을 수 없음)`); console.warn(` - valueType: ${valueType}, defaultValue: ${defaultValue}`); console.warn(` - 소스 데이터 키들:`, Object.keys(sourceData)); console.warn(` - sourceData[${sourceField}] =`, sourceData[sourceField]); return; // 값이 없으면 해당 필드는 스킵 } // 대상 필드에 값 설정 if (targetField && value !== undefined && value !== null) { insertData[targetField] = value; } }); console.log("🎯 최종 삽입 데이터 (필드매핑 적용):", insertData); } else { // 필드 매핑이 없으면 폼 데이터를 기본으로 사용 insertData = { ...context.formData }; console.log("📝 기본 삽입 데이터 (폼 기반):", insertData); } try { // 🔥 실제 삽입 API 호출 - 필수 매개변수 포함 // 필드 매핑에서 첫 번째 targetTable을 찾거나 기본값 사용 const targetTable = action.fieldMappings?.[0]?.targetTable || action.targetTable || "test_project_info"; const formDataPayload = { screenId: 0, // 제어 관리에서는 screenId가 없으므로 0 사용 tableName: targetTable, // 필드 매핑에서 대상 테이블명 가져오기 data: insertData, }; console.log("🎯 대상 테이블:", targetTable); console.log("📋 삽입할 데이터:", insertData); console.log("💾 폼 데이터 저장 요청:", formDataPayload); const result = await DynamicFormApi.saveFormData(formDataPayload); if (result.success) { console.log("✅ 삽입 성공:", result); toast.success(`데이터가 타겟 테이블에 성공적으로 삽입되었습니다.`); } else { throw new Error(result.message || "삽입 실패"); } } catch (error) { console.error("❌ 삽입 실패:", error); toast.error(`삽입 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`); throw error; } } /** * 테이블 이력 보기 액션 처리 */ private static async handleViewTableHistory( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { console.log("📜 테이블 이력 보기 액션 실행:", { config, context }); // 테이블명 결정 (설정 > 컨텍스트 > 폼 데이터) const tableName = config.historyTableName || context.tableName; if (!tableName) { toast.error("테이블명이 지정되지 않았습니다."); return false; } // 레코드 ID 가져오기 (선택사항 - 없으면 전체 테이블 이력) const recordIdField = config.historyRecordIdField || "id"; const recordIdSource = config.historyRecordIdSource || "selected_row"; let recordId: any = null; let recordLabel: string | undefined; switch (recordIdSource) { case "selected_row": // 선택된 행에서 가져오기 (선택사항) if (context.selectedRowsData && context.selectedRowsData.length > 0) { const selectedRow = context.selectedRowsData[0]; recordId = selectedRow[recordIdField]; // 라벨 필드가 지정되어 있으면 사용 if (config.historyRecordLabelField) { recordLabel = selectedRow[config.historyRecordLabelField]; } } else if (context.flowSelectedData && context.flowSelectedData.length > 0) { // 플로우 선택 데이터 폴백 const selectedRow = context.flowSelectedData[0]; recordId = selectedRow[recordIdField]; if (config.historyRecordLabelField) { recordLabel = selectedRow[config.historyRecordLabelField]; } } break; case "form_field": // 폼 필드에서 가져오기 recordId = context.formData?.[recordIdField]; if (config.historyRecordLabelField) { recordLabel = context.formData?.[config.historyRecordLabelField]; } break; case "context": // 원본 데이터에서 가져오기 recordId = context.originalData?.[recordIdField]; if (config.historyRecordLabelField) { recordLabel = context.originalData?.[config.historyRecordLabelField]; } break; } // recordId가 없어도 괜찮음 - 전체 테이블 이력 보기 console.log("📋 이력 조회 대상:", { tableName, recordId: recordId || "전체", recordLabel, mode: recordId ? "단일 레코드" : "전체 테이블", }); // 이력 모달 열기 (동적 import) try { const { TableHistoryModal } = await import("@/components/common/TableHistoryModal"); const { createRoot } = await import("react-dom/client"); // 모달 컨테이너 생성 const modalContainer = document.createElement("div"); document.body.appendChild(modalContainer); const root = createRoot(modalContainer); const closeModal = () => { root.unmount(); document.body.removeChild(modalContainer); }; root.render( React.createElement(TableHistoryModal, { open: true, onOpenChange: (open: boolean) => { if (!open) closeModal(); }, tableName, recordId, recordLabel, displayColumn: config.historyDisplayColumn, }), ); return true; } catch (error) { console.error("❌ 이력 모달 열기 실패:", error); toast.error("이력 조회 중 오류가 발생했습니다."); return false; } } /** * 엑셀 다운로드 액션 처리 */ private static async handleExcelDownload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📥 엑셀 다운로드 시작:", { config, context }); console.log("🔍 context.columnOrder 확인:", { hasColumnOrder: !!context.columnOrder, columnOrderLength: context.columnOrder?.length, columnOrder: context.columnOrder, }); console.log("🔍 context.tableDisplayData 확인:", { hasTableDisplayData: !!context.tableDisplayData, tableDisplayDataLength: context.tableDisplayData?.length, tableDisplayDataFirstRow: context.tableDisplayData?.[0], tableDisplayDataColumns: context.tableDisplayData?.[0] ? Object.keys(context.tableDisplayData[0]) : [], }); // 동적 import로 엑셀 유틸리티 로드 const { exportToExcel } = await import("@/lib/utils/excelExport"); let dataToExport: any[] = []; // 1순위: 선택된 행 데이터 if (context.selectedRowsData && context.selectedRowsData.length > 0) { dataToExport = context.selectedRowsData; console.log("✅ 선택된 행 데이터 사용:", dataToExport.length); // 선택된 행도 정렬 적용 if (context.sortBy) { console.log("🔄 선택된 행 데이터 정렬 적용:", { sortBy: context.sortBy, sortOrder: context.sortOrder, }); dataToExport = [...dataToExport].sort((a, b) => { const aVal = a[context.sortBy!]; const bVal = b[context.sortBy!]; // null/undefined 처리 if (aVal == null && bVal == null) return 0; if (aVal == null) return 1; if (bVal == null) return -1; // 숫자 비교 (문자열이어도 숫자로 변환 가능하면 숫자로 비교) const aNum = Number(aVal); const bNum = Number(bVal); // 둘 다 유효한 숫자이고, 원본 값이 빈 문자열이 아닌 경우 if (!isNaN(aNum) && !isNaN(bNum) && aVal !== "" && bVal !== "") { return context.sortOrder === "desc" ? bNum - aNum : aNum - bNum; } // 문자열 비교 (대소문자 구분 없이, 숫자 포함 문자열도 자연스럽게 정렬) const aStr = String(aVal).toLowerCase(); const bStr = String(bVal).toLowerCase(); // 자연스러운 정렬 (숫자 포함 문자열) const comparison = aStr.localeCompare(bStr, undefined, { numeric: true, sensitivity: 'base' }); return context.sortOrder === "desc" ? -comparison : comparison; }); console.log("✅ 정렬 완료:", { firstRow: dataToExport[0], lastRow: dataToExport[dataToExport.length - 1], firstSortValue: dataToExport[0]?.[context.sortBy], lastSortValue: dataToExport[dataToExport.length - 1]?.[context.sortBy], }); } } // 2순위: 화면 표시 데이터 (컬럼 순서 포함, 정렬 적용됨) else if (context.tableDisplayData && context.tableDisplayData.length > 0) { dataToExport = context.tableDisplayData; console.log("✅ 화면 표시 데이터 사용 (context):", { count: dataToExport.length, firstRow: dataToExport[0], columns: Object.keys(dataToExport[0] || {}), }); } // 2.5순위: 전역 저장소에서 화면 표시 데이터 조회 else if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); if (storedData && storedData.data.length > 0) { dataToExport = storedData.data; console.log("✅ 화면 표시 데이터 사용 (전역 저장소):", { tableName: context.tableName, count: dataToExport.length, firstRow: dataToExport[0], lastRow: dataToExport[dataToExport.length - 1], columns: Object.keys(dataToExport[0] || {}), columnOrder: storedData.columnOrder, sortBy: storedData.sortBy, sortOrder: storedData.sortOrder, // 정렬 컬럼의 첫/마지막 값 확인 firstSortValue: storedData.sortBy ? dataToExport[0]?.[storedData.sortBy] : undefined, lastSortValue: storedData.sortBy ? dataToExport[dataToExport.length - 1]?.[storedData.sortBy] : undefined, }); } // 3순위: 테이블 전체 데이터 (API 호출) else { console.log("🔄 테이블 전체 데이터 조회 중...", context.tableName); console.log("📊 정렬 정보:", { sortBy: context.sortBy, sortOrder: context.sortOrder, }); try { const { dynamicFormApi } = await import("@/lib/api/dynamicForm"); const response = await dynamicFormApi.getTableData(context.tableName, { page: 1, pageSize: 10000, // 최대 10,000개 행 sortBy: context.sortBy || "id", // 화면 정렬 또는 기본 정렬 sortOrder: context.sortOrder || "asc", // 화면 정렬 방향 또는 오름차순 }); console.log("📦 API 응답 구조:", { response, responseSuccess: response.success, responseData: response.data, responseDataType: typeof response.data, responseDataIsArray: Array.isArray(response.data), responseDataLength: Array.isArray(response.data) ? response.data.length : "N/A", }); if (response.success && response.data) { dataToExport = response.data; console.log("✅ 테이블 전체 데이터 조회 완료:", { count: dataToExport.length, firstRow: dataToExport[0], }); } else { console.error("❌ API 응답에 데이터가 없습니다:", response); } } catch (error) { console.error("❌ 테이블 데이터 조회 실패:", error); } } } // 4순위: 폼 데이터 else if (context.formData && Object.keys(context.formData).length > 0) { dataToExport = [context.formData]; console.log("✅ 폼 데이터 사용:", dataToExport); } console.log("📊 최종 다운로드 데이터:", { selectedRowsData: context.selectedRowsData, selectedRowsLength: context.selectedRowsData?.length, formData: context.formData, tableName: context.tableName, dataToExport, dataToExportType: typeof dataToExport, dataToExportIsArray: Array.isArray(dataToExport), dataToExportLength: Array.isArray(dataToExport) ? dataToExport.length : "N/A", }); // 배열이 아니면 배열로 변환 if (!Array.isArray(dataToExport)) { console.warn("⚠️ dataToExport가 배열이 아닙니다. 변환 시도:", dataToExport); // 객체인 경우 배열로 감싸기 if (typeof dataToExport === "object" && dataToExport !== null) { dataToExport = [dataToExport]; } else { toast.error("다운로드할 데이터 형식이 올바르지 않습니다."); return false; } } if (dataToExport.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return false; } // 파일명 생성 const fileName = config.excelFileName || `${context.tableName || "데이터"}_${new Date().toISOString().split("T")[0]}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; // 🆕 컬럼 순서 재정렬 (화면에 표시된 순서대로) let columnOrder: string[] | undefined = context.columnOrder; // columnOrder가 없으면 tableDisplayData에서 추출 시도 if (!columnOrder && context.tableDisplayData && context.tableDisplayData.length > 0) { columnOrder = Object.keys(context.tableDisplayData[0]); console.log("📊 tableDisplayData에서 컬럼 순서 추출:", columnOrder); } if (columnOrder && columnOrder.length > 0 && dataToExport.length > 0) { console.log("🔄 컬럼 순서 재정렬 시작:", { columnOrder, originalColumns: Object.keys(dataToExport[0] || {}), }); dataToExport = dataToExport.map((row: any) => { const reorderedRow: any = {}; // 1. columnOrder에 있는 컬럼들을 순서대로 추가 columnOrder!.forEach((colName: string) => { if (colName in row) { reorderedRow[colName] = row[colName]; } }); // 2. columnOrder에 없는 나머지 컬럼들 추가 (끝에 배치) Object.keys(row).forEach((key) => { if (!(key in reorderedRow)) { reorderedRow[key] = row[key]; } }); return reorderedRow; }); console.log("✅ 컬럼 순서 재정렬 완료:", { reorderedColumns: Object.keys(dataToExport[0] || {}), }); } else { console.log("⏭️ 컬럼 순서 재정렬 스킵:", { hasColumnOrder: !!columnOrder, columnOrderLength: columnOrder?.length, hasTableDisplayData: !!context.tableDisplayData, dataToExportLength: dataToExport.length, }); } console.log("📥 엑셀 다운로드 실행:", { fileName, sheetName, includeHeaders, dataCount: dataToExport.length, firstRow: dataToExport[0], columnOrder: context.columnOrder, }); // 엑셀 다운로드 실행 await exportToExcel(dataToExport, fileName, sheetName, includeHeaders); toast.success(config.successMessage || "엑셀 파일이 다운로드되었습니다."); return true; } catch (error) { console.error("❌ 엑셀 다운로드 실패:", error); toast.error(config.errorMessage || "엑셀 다운로드 중 오류가 발생했습니다."); return false; } } /** * 엑셀 업로드 액션 처리 */ private static async handleExcelUpload(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📤 엑셀 업로드 모달 열기:", { config, context, userId: context.userId, tableName: context.tableName, }); // 동적 import로 모달 컴포넌트 로드 const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal"); const { createRoot } = await import("react-dom/client"); // 모달 컨테이너 생성 const modalContainer = document.createElement("div"); document.body.appendChild(modalContainer); const root = createRoot(modalContainer); const closeModal = () => { root.unmount(); document.body.removeChild(modalContainer); }; // localStorage 디버깅 const modalId = `excel-upload-${context.tableName || ""}`; const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`; console.log("🔍 엑셀 업로드 모달 localStorage 확인:", { modalId, userId: context.userId, storageKey, savedSize: localStorage.getItem(storageKey), }); root.render( React.createElement(ExcelUploadModal, { open: true, onOpenChange: (open: boolean) => { if (!open) { // 모달 닫을 때 localStorage 확인 console.log("🔍 모달 닫을 때 localStorage:", { storageKey, savedSize: localStorage.getItem(storageKey), }); closeModal(); } }, tableName: context.tableName || "", uploadMode: config.excelUploadMode || "insert", keyColumn: config.excelKeyColumn, userId: context.userId, onSuccess: () => { // 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨 context.onRefresh?.(); closeModal(); }, }), ); return true; } catch (error) { console.error("❌ 엑셀 업로드 모달 열기 실패:", error); toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다."); return false; } } /** * 바코드 스캔 액션 처리 */ private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📷 바코드 스캔 모달 열기:", { config, context }); // 동적 import로 모달 컴포넌트 로드 const { BarcodeScanModal } = await import("@/components/common/BarcodeScanModal"); const { createRoot } = await import("react-dom/client"); // 모달 컨테이너 생성 const modalContainer = document.createElement("div"); document.body.appendChild(modalContainer); const root = createRoot(modalContainer); const closeModal = () => { root.unmount(); document.body.removeChild(modalContainer); }; root.render( React.createElement(BarcodeScanModal, { open: true, onOpenChange: (open: boolean) => { if (!open) closeModal(); }, targetField: config.barcodeTargetField, barcodeFormat: config.barcodeFormat || "all", autoSubmit: config.barcodeAutoSubmit || false, userId: context.userId, onScanSuccess: (barcode: string) => { console.log("✅ 바코드 스캔 성공:", barcode); // 대상 필드에 값 입력 if (config.barcodeTargetField && context.onFormDataChange) { context.onFormDataChange({ ...context.formData, [config.barcodeTargetField]: barcode, }); } toast.success(`바코드 스캔 완료: ${barcode}`); // 자동 제출 옵션이 켜져있으면 저장 if (config.barcodeAutoSubmit) { this.handleSave(config, context); } closeModal(); }, }), ); return true; } catch (error) { console.error("❌ 바코드 스캔 모달 열기 실패:", error); toast.error("바코드 스캔 중 오류가 발생했습니다."); return false; } } /** * 코드 병합 액션 처리 */ private static async handleCodeMerge(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("🔀 코드 병합 액션 실행:", { config, context }); // 선택된 행 데이터 확인 const selectedRows = context.selectedRowsData || context.flowSelectedData; if (!selectedRows || selectedRows.length !== 2) { toast.error("병합할 두 개의 항목을 선택해주세요."); return false; } // 병합할 컬럼명 확인 const columnName = config.mergeColumnName; if (!columnName) { toast.error("병합할 컬럼명이 설정되지 않았습니다."); return false; } // 두 개의 선택된 행에서 컬럼 값 추출 const [row1, row2] = selectedRows; const value1 = row1[columnName]; const value2 = row2[columnName]; if (!value1 || !value2) { toast.error(`선택한 항목에 "${columnName}" 값이 없습니다.`); return false; } if (value1 === value2) { toast.error("같은 값은 병합할 수 없습니다."); return false; } // 병합 방향 선택 모달 표시 const confirmed = await new Promise<{ confirmed: boolean; oldValue: string; newValue: string }>((resolve) => { const modalHtml = `

코드 병합 방향 선택

어느 코드로 병합하시겠습니까?

`; const modalContainer = document.createElement("div"); modalContainer.innerHTML = modalHtml; document.body.appendChild(modalContainer); const option1Btn = modalContainer.querySelector("#merge-option-1") as HTMLButtonElement; const option2Btn = modalContainer.querySelector("#merge-option-2") as HTMLButtonElement; const cancelBtn = modalContainer.querySelector("#merge-cancel") as HTMLButtonElement; // 호버 효과 [option1Btn, option2Btn].forEach((btn) => { btn.addEventListener("mouseenter", () => { btn.style.borderColor = "#3b82f6"; btn.style.background = "#eff6ff"; }); btn.addEventListener("mouseleave", () => { btn.style.borderColor = "#e5e7eb"; btn.style.background = "white"; }); }); option1Btn.addEventListener("click", () => { document.body.removeChild(modalContainer); resolve({ confirmed: true, oldValue: value2, newValue: value1 }); }); option2Btn.addEventListener("click", () => { document.body.removeChild(modalContainer); resolve({ confirmed: true, oldValue: value1, newValue: value2 }); }); cancelBtn.addEventListener("click", () => { document.body.removeChild(modalContainer); resolve({ confirmed: false, oldValue: "", newValue: "" }); }); }); if (!confirmed.confirmed) { return false; } const { oldValue, newValue } = confirmed; // 미리보기 표시 (옵션) if (config.mergeShowPreview !== false) { const { apiClient } = await import("@/lib/api/client"); const previewResponse = await apiClient.post("/code-merge/preview", { columnName, oldValue, }); if (previewResponse.data.success) { const preview = previewResponse.data.data; const totalRows = preview.totalAffectedRows; const confirmMerge = confirm( `⚠️ 코드 병합 확인\n\n` + `${oldValue} → ${newValue}\n\n` + `영향받는 데이터:\n` + `- 테이블 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + `데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` + `계속하시겠습니까?` ); if (!confirmMerge) { return false; } } } // 병합 실행 toast.loading("코드 병합 중...", { duration: Infinity }); const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.post("/code-merge/merge-all-tables", { columnName, oldValue, newValue, }); toast.dismiss(); if (response.data.success) { const data = response.data.data; toast.success( `코드 병합 완료!\n` + `${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트` ); // 화면 새로고침 context.onRefresh?.(); context.onFlowRefresh?.(); return true; } else { toast.error(response.data.message || "코드 병합에 실패했습니다."); return false; } } catch (error: any) { console.error("❌ 코드 병합 실패:", error); toast.dismiss(); toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다."); return false; } } /** * 폼 데이터 유효성 검사 */ private static validateFormData(formData: Record): { isValid: boolean; errors: string[]; } { const errors: string[] = []; // 기본적인 유효성 검사 로직 Object.entries(formData).forEach(([key, value]) => { // 빈 값 체크 (null, undefined, 빈 문자열) if (value === null || value === undefined || value === "") { // 필수 필드는 향후 컴포넌트 설정에서 확인 가능 console.warn(`필드 '${key}'가 비어있습니다.`); } // 기본 타입 검증 if (typeof value === "string" && value.trim() === "") { console.warn(`필드 '${key}'가 공백만 포함되어 있습니다.`); } }); // 최소한 하나의 필드는 있어야 함 if (Object.keys(formData).length === 0) { errors.push("저장할 데이터가 없습니다."); } return { isValid: errors.length === 0, errors, }; } } /** * 기본 버튼 액션 설정들 */ export const DEFAULT_BUTTON_ACTIONS: Record> = { save: { type: "save", validateForm: true, confirmMessage: "저장하시겠습니까?", successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다.", }, delete: { type: "delete", confirmMessage: "정말 삭제하시겠습니까?", successMessage: "삭제되었습니다.", errorMessage: "삭제 중 오류가 발생했습니다.", }, navigate: { type: "navigate", }, modal: { type: "modal", modalSize: "md", }, edit: { type: "edit", successMessage: "편집되었습니다.", }, control: { type: "control", }, view_table_history: { type: "view_table_history", historyRecordIdField: "id", historyRecordIdSource: "selected_row", }, excel_download: { type: "excel_download", excelIncludeHeaders: true, successMessage: "엑셀 파일이 다운로드되었습니다.", errorMessage: "엑셀 다운로드 중 오류가 발생했습니다.", }, excel_upload: { type: "excel_upload", excelUploadMode: "insert", confirmMessage: "엑셀 파일을 업로드하시겠습니까?", successMessage: "엑셀 파일이 업로드되었습니다.", errorMessage: "엑셀 업로드 중 오류가 발생했습니다.", }, barcode_scan: { type: "barcode_scan", barcodeFormat: "all", barcodeAutoSubmit: false, }, code_merge: { type: "code_merge", mergeShowPreview: true, confirmMessage: "선택한 두 항목을 병합하시겠습니까?", successMessage: "코드 병합이 완료되었습니다.", errorMessage: "코드 병합 중 오류가 발생했습니다.", }, };