"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 { apiClient } from "@/lib/api/client"; import type { ExtendedControlContext } from "@/types/control-management"; /** * 버튼 액션 타입 정의 */ export type ButtonActionType = | "save" // 저장 | "delete" // 삭제 | "edit" // 편집 | "copy" // 복사 (품목코드 초기화) | "navigate" // 페이지 이동 | "openRelatedModal" // 연관 데이터 버튼의 선택 데이터로 모달 열기 | "openModalWithData" // 데이터를 전달하면서 모달 열기 | "modal" // 모달 열기 | "control" // 제어 흐름 | "view_table_history" // 테이블 이력 보기 | "excel_download" // 엑셀 다운로드 | "excel_upload" // 엑셀 업로드 | "barcode_scan" // 바코드 스캔 | "code_merge" // 코드 병합 // | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합 | "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간) | "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT) /** * 버튼 액션 설정 */ export interface ButtonActionConfig { type: ButtonActionType; // 저장/제출 관련 saveEndpoint?: string; validateForm?: boolean; // 네비게이션 관련 targetUrl?: string; targetScreenId?: number; // 모달/팝업 관련 modalTitle?: string; modalTitleBlocks?: Array<{ // 🆕 블록 기반 제목 (우선순위 높음) id: string; type: "text" | "field"; value: string; // type=text: 텍스트 내용, type=field: 컬럼명 tableName?: string; // type=field일 때 테이블명 label?: string; // type=field일 때 표시용 라벨 }>; modalDescription?: string; modalSize?: "sm" | "md" | "lg" | "xl"; popupWidth?: number; popupHeight?: number; dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용) fieldMappings?: Array<{ sourceField: string; targetField: string }>; // 🆕 필드 매핑 (openModalWithData용) // 확인 메시지 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 시 키 컬럼 excelNumberingRuleId?: string; // 채번 규칙 ID (단일 테이블용) excelNumberingTargetColumn?: string; // 채번 적용 컬럼 (단일 테이블용) excelAfterUploadFlows?: Array<{ flowId: string; order: number }>; // 업로드 후 제어 실행 // 바코드 스캔 관련 barcodeTargetField?: string; // 스캔 결과를 입력할 필드명 barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all") barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부 // 코드 병합 관련 mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code") mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true) // 위치정보 관련 geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블) geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude") geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude") geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy") geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time") geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true) geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000) geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0) geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false) geolocationKeyField?: string; // DB UPDATE 시 WHERE 조건에 사용할 키 필드 (예: "user_id") geolocationKeySourceField?: string; // 키 값 소스 (예: "__userId__" 또는 폼 필드명) geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부 geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능) geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status") geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active") geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id") geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") // 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에) geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부 geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles") geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update) geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status") geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive") geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용 geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용 geolocationSecondInsertFields?: Record; // INSERT 모드에서 추가로 넣을 필드들 // 🆕 연속 위치 추적 설정 (update_field 액션의 updateWithTracking 옵션용) trackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초) trackingTripIdField?: string; // 운행 ID를 저장할 필드명 (예: "trip_id") trackingAutoGenerateTripId?: boolean; // 운행 ID 자동 생성 여부 (기본: true) trackingDepartureField?: string; // 출발지 필드명 (formData에서 가져옴) trackingArrivalField?: string; // 도착지 필드명 (formData에서 가져옴) trackingVehicleIdField?: string; // 차량 ID 필드명 (formData에서 가져옴) trackingStatusOnStart?: string; // 추적 시작 시 상태값 (예: "active") trackingStatusOnStop?: string; // 추적 종료 시 상태값 (예: "completed") trackingStatusField?: string; // 상태 필드명 (vehicles 테이블 등) trackingStatusTableName?: string; // 상태 변경 대상 테이블명 trackingStatusKeyField?: string; // 상태 변경 키 필드 (예: "user_id") trackingStatusKeySourceField?: string; // 키 값 소스 (예: "__userId__") // 필드 값 교환 관련 (출발지 ↔ 목적지) swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure") swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination") swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도) // 필드 값 변경 관련 (특정 필드를 특정 값으로 변경) updateTargetField?: string; // 변경할 필드명 (예: "status") updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active") updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true) updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경 updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시) updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용) updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드) // 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용) updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부 updateGeolocationLatField?: string; // 위도 저장 필드 updateGeolocationLngField?: string; // 경도 저장 필드 // 🆕 필드 값 변경 + 연속 위치 추적 (update_field 액션에서 사용) updateWithTracking?: boolean; // 연속 위치 추적 사용 여부 updateTrackingMode?: "start" | "stop"; // 추적 모드 (시작/종료) updateTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000) updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택) updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택) // 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용) emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true) emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초) // 편집 관련 (수주관리 등 그룹별 다중 레코드 편집) editMode?: "modal" | "navigate" | "inline"; // 편집 모드 editModalTitle?: string; // 편집 모달 제목 editModalDescription?: string; // 편집 모달 설명 groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"]) // 데이터 전달 관련 (transferData 액션용) dataTransfer?: { // 소스 설정 sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등) sourceComponentType?: string; // 소스 컴포넌트 타입 // 타겟 설정 targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면) // 타겟이 컴포넌트인 경우 targetComponentId?: string; // 타겟 컴포넌트 ID // 타겟이 화면인 경우 targetScreenId?: number; // 타겟 화면 ID // 데이터 매핑 규칙 mappingRules: Array<{ sourceField: string; // 소스 필드명 targetField: string; // 타겟 필드명 transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수 defaultValue?: any; // 기본값 }>; // 전달 옵션 mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append) clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화 confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지 confirmMessage?: string; // 확인 메시지 내용 // 검증 validation?: { requireSelection?: boolean; // 선택 필수 (기본: true) minSelection?: number; // 최소 선택 개수 maxSelection?: number; // 최대 선택 개수 }; }; // 연관 데이터 버튼 모달 열기 관련 relatedModalConfig?: { targetScreenId: number; // 열릴 모달 화면 ID componentId?: string; // 특정 RelatedDataButtons 컴포넌트 지정 (선택사항) }; // 즉시 저장 (Quick Insert) 관련 quickInsertConfig?: { targetTable: string; // 저장할 테이블명 columnMappings: Array<{ targetColumn: string; // 대상 테이블의 컬럼명 sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입 sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용) sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼 fixedValue?: any; // 고정값 userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드 }>; duplicateCheck?: { enabled: boolean; // 중복 체크 활성화 여부 columns?: string[]; // 중복 체크할 컬럼들 errorMessage?: string; // 중복 시 에러 메시지 }; afterInsert?: { refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이) clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화 showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true) successMessage?: string; // 성공 메시지 }; }; } /** * 버튼 액션 실행 컨텍스트 */ 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; // 플로우 새로고침 콜백 onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 // 테이블 선택된 행 정보 (다중 선택 액션용) selectedRows?: any[]; selectedRowsData?: any[]; // 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용) flowSelectedData?: any[]; flowSelectedStepId?: number | null; // 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용) allComponents?: any[]; // 제어 실행을 위한 추가 정보 buttonId?: string; // 테이블 정렬 정보 (엑셀 다운로드용) sortBy?: string; // 정렬 컬럼명 sortOrder?: "asc" | "desc"; // 정렬 방향 columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서) tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨) // 🆕 엑셀 다운로드 개선을 위한 추가 필드 filterConditions?: Record; // 필터 조건 (예: { status: "active", dept: "dev" }) searchTerm?: string; // 검색어 searchColumn?: string; // 검색 대상 컬럼 visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함) columnLabels?: Record; // 컬럼명 → 라벨명 매핑 (한글) currentPage?: number; // 현재 페이지 pageSize?: number; // 페이지 크기 totalItems?: number; // 전체 항목 수 // 🆕 컴포넌트별 설정 (parentDataMapping 등) componentConfigs?: Record; // 컴포넌트 ID → 컴포넌트 설정 // 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터) splitPanelParentData?: Record; // 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용) splitPanelContext?: { selectedLeftData?: Record; refreshRightPanel?: () => void; }; // 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달) savedData?: any; } /** * 🆕 특수 키워드를 실제 값으로 변환하는 헬퍼 함수 * 지원하는 키워드: * - __userId__ : 로그인한 사용자 ID * - __userName__ : 로그인한 사용자 이름 * - __companyCode__ : 로그인한 사용자의 회사 코드 * - __screenId__ : 현재 화면 ID * - __tableName__ : 현재 테이블명 */ export function resolveSpecialKeyword(sourceField: string | undefined, context: ButtonActionContext): any { if (!sourceField) return undefined; // 특수 키워드 처리 switch (sourceField) { case "__userId__": console.log("🔑 특수 키워드 변환: __userId__ →", context.userId); return context.userId; case "__userName__": console.log("🔑 특수 키워드 변환: __userName__ →", context.userName); return context.userName; case "__companyCode__": console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode); return context.companyCode; case "__screenId__": console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId); return context.screenId; case "__tableName__": console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName); return context.tableName; default: // 일반 폼 데이터에서 가져오기 return context.formData?.[sourceField]; } } /** * 버튼 액션 실행기 */ 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 "copy": return await this.handleCopy(config, context); case "navigate": return this.handleNavigate(config, context); case "openModalWithData": return await this.handleOpenModalWithData(config, context); case "openRelatedModal": return await this.handleOpenRelatedModal(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); case "transferData": return await this.handleTransferData(config, context); // case "empty_vehicle": // return await this.handleEmptyVehicle(config, context); case "operation_control": return await this.handleOperationControl(config, context); case "swap_fields": return await this.handleSwapFields(config, context); case "quickInsert": return await this.handleQuickInsert(config, context); default: console.warn(`지원되지 않는 액션 타입: ${config.type}`); return false; } } catch (error) { console.error("버튼 액션 실행 오류:", error); toast.error(config.errorMessage || "작업 중 오류가 발생했습니다."); return false; } } /** * 필수 항목 검증 */ private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } { const missingFields: string[] = []; const { formData, allComponents } = context; if (!allComponents || allComponents.length === 0) { console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵"); return { isValid: true, missingFields: [] }; } allComponents.forEach((component: any) => { // 컴포넌트의 required 속성 확인 (여러 위치에서 체크) const isRequired = component.required === true || component.style?.required === true || component.componentConfig?.required === true; const columnName = component.columnName || component.style?.columnName; const label = component.label || component.style?.label || columnName; if (isRequired && columnName) { const value = formData[columnName]; // 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열) if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) { console.log("🔍 [validateRequiredFields] 필수 항목 누락:", { columnName, label, value, isRequired, }); missingFields.push(label || columnName); } } }); return { isValid: missingFields.length === 0, missingFields, }; } /** * 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반) */ private static saveCallCount = 0; // 🆕 호출 횟수 추적 private static saveLock: Map = new Map(); // 🆕 중복 호출 방지 락 private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise { this.saveCallCount++; const callId = this.saveCallCount; const { formData, originalData, tableName, screenId, onSave } = context; // 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시 const formDataHash = JSON.stringify(Object.keys(formData).sort()); const lockKey = `${screenId}-${tableName}-${formDataHash}`; const lastCallTime = this.saveLock.get(lockKey) || 0; const now = Date.now(); const timeDiff = now - lastCallTime; console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 }); if (timeDiff < 2000) { console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, { lockKey: lockKey.slice(0, 50), timeDiff, }); return true; // 중복 호출은 성공으로 처리 } this.saveLock.set(lockKey, now); console.log(`💾 [handleSave #${callId}] 저장 시작:`, { callId, formDataKeys: Object.keys(formData), tableName, screenId, hasOnSave: !!onSave, }); // ✅ 필수 항목 검증 console.log("🔍 [handleSave] 필수 항목 검증 시작:", { hasAllComponents: !!context.allComponents, allComponentsLength: context.allComponents?.length || 0, }); const requiredValidation = this.validateRequiredFields(context); if (!requiredValidation.isValid) { console.log("❌ [handleSave] 필수 항목 누락:", requiredValidation.missingFields); toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`); return false; } console.log("✅ [handleSave] 필수 항목 검증 통과"); // 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용 if (onSave) { console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행"); try { await onSave(); return true; } catch (error) { console.error("❌ [handleSave] onSave 콜백 실행 오류:", error); throw error; } } console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행"); // 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집) // context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함 // skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음 const beforeSaveEventDetail = { formData: context.formData, skipDefaultSave: false, }; window.dispatchEvent( new CustomEvent("beforeFormSave", { detail: beforeSaveEventDetail, }), ); // 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함 await new Promise((resolve) => setTimeout(resolve, 100)); // 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기 if (beforeSaveEventDetail.skipDefaultSave) { console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)"); return true; } console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData); // 🆕 렉 구조 컴포넌트 일괄 저장 감지 let rackStructureLocations: any[] | undefined; let rackStructureFieldKey = "_rackStructureLocations"; let hasEmptyRackStructureField = false; // formData에서 렉 구조 데이터 또는 빈 배열 찾기 for (const [key, value] of Object.entries(context.formData || {})) { // 배열인 경우만 체크 if (Array.isArray(value)) { if (value.length > 0 && value[0]) { const firstItem = value[0]; const isNewFormat = firstItem.location_code && firstItem.location_name && firstItem.row_num !== undefined && firstItem.level_num !== undefined; const isOldFormat = firstItem.locationCode && firstItem.locationName && firstItem.rowNum !== undefined && firstItem.levelNum !== undefined; if (isNewFormat || isOldFormat) { console.log("🏗️ [handleSave] 렉 구조 데이터 감지 - 필드:", key); rackStructureLocations = value; rackStructureFieldKey = key; break; } } else if (value.length === 0 && key.startsWith("comp_")) { // comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음 // allComponents에서 확인 const rackStructureComponentInLayout = context.allComponents?.find( (comp: any) => comp.type === "component" && comp.componentId === "rack-structure" && comp.columnName === key, ); if (rackStructureComponentInLayout) { console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 (미리보기 없음) - 필드:", key); hasEmptyRackStructureField = true; rackStructureFieldKey = key; } } } } // 렉 구조 컴포넌트가 있지만 미리보기 데이터가 없는 경우 if (hasEmptyRackStructureField && (!rackStructureLocations || rackStructureLocations.length === 0)) { alert("미리보기를 먼저 생성해주세요.\n\n렉 구조 조건을 설정한 후 '미리보기 생성' 버튼을 클릭하세요."); return false; } // 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음) // 이 경우 일반 저장을 차단하고 미리보기 생성을 요구 const isRackStructureScreen = context.tableName === "warehouse_location" && context.formData?.floor && context.formData?.zone && !rackStructureLocations; if (isRackStructureScreen) { console.log("🏗️ [handleSave] 렉 구조 등록 화면 감지 - 미리보기 데이터 없음"); alert( "렉 구조 등록 화면입니다.\n\n" + "미리보기를 먼저 생성해주세요.\n" + "- 중복된 위치가 있으면 미리보기가 생성되지 않습니다.\n" + "- 기존 데이터를 삭제하거나 다른 열/단을 선택해주세요.", ); return false; } // 렉 구조 데이터가 있으면 일괄 저장 if (rackStructureLocations && rackStructureLocations.length > 0) { console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 - 일괄 저장 시작:", rackStructureLocations.length, "개"); return await this.handleRackStructureBatchSave(config, context, rackStructureLocations, rackStructureFieldKey); } // 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조) console.log("🔍 [handleSave] formData 구조 확인:", { isFormDataArray: Array.isArray(context.formData), keys: Object.keys(context.formData), values: Object.entries(context.formData).map(([key, value]) => ({ key, isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, firstItem: Array.isArray(value) && value.length > 0 ? { hasOriginalData: !!value[0]?.originalData, hasFieldGroups: !!value[0]?.fieldGroups, keys: Object.keys(value[0] || {}), } : null, })), }); // 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정) if (Array.isArray(context.formData)) { console.log( "⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀", ); console.log("⚠️ [handleSave] formData 배열:", context.formData); // ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀 return true; // 성공으로 반환 } const selectedItemsKeys = Object.keys(context.formData).filter((key) => { const value = context.formData[key]; console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, { isArray: Array.isArray(value), length: Array.isArray(value) ? value.length : 0, firstItem: Array.isArray(value) && value.length > 0 ? { keys: Object.keys(value[0] || {}), hasOriginalData: !!value[0]?.originalData, hasFieldGroups: !!value[0]?.fieldGroups, actualValue: value[0], } : null, }); return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups; }); if (selectedItemsKeys.length > 0) { console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys); return await this.handleBatchSave(config, context, selectedItemsKeys); } else { console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행"); console.log("⚠️ [handleSave] formData 전체 내용:", context.formData); } // 🆕 RepeaterFieldGroup JSON 문자열 파싱 및 저장 처리 // formData에 JSON 배열 문자열이 저장된 경우 처리 (반복_필드_그룹 등) const repeaterJsonKeys = Object.keys(context.formData).filter((key) => { const value = context.formData[key]; if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) && parsed.length > 0 && parsed[0]._targetTable; } catch { return false; } } return false; }); if (repeaterJsonKeys.length > 0) { console.log("🔄 [handleSave] RepeaterFieldGroup JSON 문자열 감지:", repeaterJsonKeys); // 🆕 상단 폼 데이터(마스터 정보) 추출 // RepeaterFieldGroup JSON과 컴포넌트 키를 제외한 나머지가 마스터 정보 const masterFields: Record = {}; Object.keys(context.formData).forEach((fieldKey) => { // 제외 조건 if (fieldKey.startsWith("comp_")) return; if (fieldKey.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) return; if (fieldKey.endsWith("_label") || fieldKey.endsWith("_value_label")) return; const value = context.formData[fieldKey]; // JSON 배열 문자열 제외 (RepeaterFieldGroup 데이터) if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) return; // 객체 타입인 경우 (범용_폼_모달 등) 내부 필드를 펼쳐서 추가 if (typeof value === "object" && value !== null && !Array.isArray(value)) { Object.entries(value).forEach(([innerKey, innerValue]) => { if (innerKey.endsWith("_label") || innerKey.endsWith("_value_label")) return; if (innerValue !== undefined && innerValue !== null && innerValue !== "") { masterFields[innerKey] = innerValue; } }); return; } // 유효한 값만 포함 if (value !== undefined && value !== null && value !== "") { masterFields[fieldKey] = value; } }); console.log("📋 [handleSave] 상단 마스터 정보 (모든 품목에 적용):", masterFields); for (const key of repeaterJsonKeys) { try { const parsedData = JSON.parse(context.formData[key]); const repeaterTargetTable = parsedData[0]?._targetTable; if (!repeaterTargetTable) { console.warn(`⚠️ [handleSave] RepeaterFieldGroup targetTable 없음 (key: ${key})`); continue; } console.log(`📦 [handleSave] RepeaterFieldGroup 저장 시작: ${repeaterTargetTable}, ${parsedData.length}건`); // 🆕 품목 고유 필드 목록 (RepeaterFieldGroup 설정에서 가져옴) // 첫 번째 아이템의 _repeaterFields에서 추출 const repeaterFields: string[] = parsedData[0]?._repeaterFields || []; const itemOnlyFields = new Set([...repeaterFields, 'id']); // id는 항상 포함 console.log("📋 [handleSave] RepeaterFieldGroup 품목 필드:", repeaterFields); for (const item of parsedData) { // 메타 필드 제거 const { _targetTable, _isNewItem, _existingRecord, _originalItemIds, _deletedItemIds, _repeaterFields, ...itemData } = item; // 🔧 품목 고유 필드만 추출 (RepeaterFieldGroup 설정 기반) const itemOnlyData: Record = {}; Object.keys(itemData).forEach((field) => { if (itemOnlyFields.has(field)) { itemOnlyData[field] = itemData[field]; } }); // 🔧 마스터 정보 + 품목 고유 정보 병합 // masterFields: 상단 폼에서 수정한 최신 마스터 정보 // itemOnlyData: 품목 고유 필드만 (품번, 품명, 수량 등) const dataWithMeta: Record = { ...masterFields, // 상단 마스터 정보 (최신) ...itemOnlyData, // 품목 고유 필드만 created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, }; // 불필요한 필드 제거 Object.keys(dataWithMeta).forEach((field) => { if (field.endsWith("_label") || field.endsWith("_value_label") || field.endsWith("_numberingRuleId")) { delete dataWithMeta[field]; } }); // 새 레코드 vs 기존 레코드 판단 const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined; console.log(`📦 [handleSave] 저장할 데이터 (${isNewRecord ? 'INSERT' : 'UPDATE'}):`, { id: item.id, dataWithMeta, }); if (isNewRecord) { // INSERT - DynamicFormApi 사용하여 제어관리 실행 delete dataWithMeta.id; const insertResult = await DynamicFormApi.saveFormData({ screenId: context.screenId || 0, tableName: repeaterTargetTable, data: dataWithMeta as Record, }); console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data); } else if (item.id && _existingRecord === true) { // UPDATE - 기존 레코드 const originalData = { id: item.id }; const updatedData = { ...dataWithMeta, id: item.id }; const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, { originalData, updatedData, }); console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data); } } } catch (err) { console.error(`❌ [handleSave] RepeaterFieldGroup 저장 실패 (key: ${key}):`, err); } } // RepeaterFieldGroup 저장 완료 후 새로고침 console.log("✅ [handleSave] RepeaterFieldGroup 저장 완료"); context.onRefresh?.(); context.onFlowRefresh?.(); window.dispatchEvent(new CustomEvent("closeEditModal")); window.dispatchEvent(new CustomEvent("saveSuccessInModal")); return true; } // 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 // 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장 const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData); if (universalFormModalResult.handled) { console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료"); return universalFormModalResult.success; } // 폼 유효성 검사 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); // 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리 // originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨 // 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인 const hasRealOriginalData = originalData && Object.keys(originalData).length > 0; // 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단 // 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리 const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== ""; const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue; console.log("🔍 [handleSave] INSERT/UPDATE 판단:", { hasOriginalData: !!originalData, hasRealOriginalData, hasIdInFormData, originalDataKeys: originalData ? Object.keys(originalData) : [], primaryKeyValue, isUpdate, primaryKeys, }); let saveResult; if (isUpdate) { // UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우) console.log("🔄 UPDATE 모드로 저장:", { primaryKeyValue, hasOriginalData: !!originalData, hasIdInFormData, updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)", }); if (hasRealOriginalData) { // 부분 업데이트: 변경된 필드만 업데이트 console.log("📝 부분 업데이트 실행 (변경된 필드만)"); saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName); } else { // 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우) console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)"); 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, // formDataCompanyCode: formData.company_code, // defaultWriterValue: writerValue, // companyCodeValue, // }); // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) console.log("🔍 채번 규칙 할당 체크 시작"); 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); // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 if (Object.keys(fieldsWithNumbering).length > 0) { console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); let hasAllocationFailure = false; const failedFields: string[] = []; for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`); const allocateResult = await allocateNumberingCode(ruleId); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`); formData[fieldName] = newCode; } else { console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error); // 🆕 기존 값이 빈 문자열이면 실패로 표시 if (!formData[fieldName] || formData[fieldName] === "") { hasAllocationFailure = true; failedFields.push(fieldName); } } } catch (allocateError) { console.error(`❌ ${fieldName} 코드 할당 오류:`, allocateError); // 🆕 기존 값이 빈 문자열이면 실패로 표시 if (!formData[fieldName] || formData[fieldName] === "") { hasAllocationFailure = true; failedFields.push(fieldName); } } } // 🆕 채번 규칙 할당 실패 시 저장 중단 if (hasAllocationFailure) { const fieldNames = failedFields.join(", "); toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`); console.error(`❌ 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`); console.error("💡 해결 방법: 화면관리에서 해당 필드의 채번 규칙 설정을 확인하세요."); return false; } } console.log("✅ 채번 규칙 할당 완료"); console.log("📦 최종 formData:", JSON.stringify(formData, null, 2)); // 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터) // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함 // 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생 // 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감 const rawSplitPanelData = context.splitPanelParentData || {}; // INSERT 모드에서는 연결에 필요한 필드만 추출 const cleanedSplitPanelData: Record = {}; // 필수 연결 필드: company_code (멀티테넌시) if (rawSplitPanelData.company_code) { cleanedSplitPanelData.company_code = rawSplitPanelData.company_code; } // 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴) const linkFieldPatterns = ["_code", "_id"]; const excludeFields = [ "id", "company_code", "created_date", "updated_date", "created_at", "updated_at", "writer", "created_by", "updated_by", ]; for (const [key, value] of Object.entries(rawSplitPanelData)) { if (excludeFields.includes(key)) continue; if (value === undefined || value === null) continue; // 연결 필드 패턴 확인 const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern)); if (isLinkField) { cleanedSplitPanelData[key] = value; console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`); } } if (Object.keys(rawSplitPanelData).length > 0) { console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData)); console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData); } const dataWithUserInfo = { ...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용 ...formData, // 폼 데이터가 우선 (덮어쓰기 가능) writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId created_by: writerValue, // created_by는 항상 로그인한 사람 updated_by: writerValue, // updated_by는 항상 로그인한 사람 company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode }; // 🔧 formData에서도 id 제거 (신규 INSERT이므로) if ("id" in dataWithUserInfo && !formData.id) { console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id); delete dataWithUserInfo.id; } // _numberingRuleId 필드 제거 (실제 저장하지 않음) for (const key of Object.keys(dataWithUserInfo)) { if (key.endsWith("_numberingRuleId")) { delete dataWithUserInfo[key]; } } // 🆕 반복 필드 그룹에서 삭제된 항목 처리 // formData의 각 필드에서 _deletedItemIds가 있는지 확인 console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo)); for (const [key, value] of Object.entries(dataWithUserInfo)) { console.log(`🔍 [handleSave] 필드 검사: ${key}`, { type: typeof value, isArray: Array.isArray(value), isString: typeof value === "string", valuePreview: typeof value === "string" ? value.substring(0, 100) : value, }); let parsedValue = value; // JSON 문자열인 경우 파싱 시도 if (typeof value === "string" && value.startsWith("[")) { try { parsedValue = JSON.parse(value); console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue); } catch (e) { // 파싱 실패하면 원본 값 유지 } } if (Array.isArray(parsedValue) && parsedValue.length > 0) { const firstItem = parsedValue[0]; const deletedItemIds = firstItem?._deletedItemIds; const targetTable = firstItem?._targetTable; console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, { firstItemKeys: firstItem ? Object.keys(firstItem) : [], deletedItemIds, targetTable, }); if (deletedItemIds && deletedItemIds.length > 0 && targetTable) { console.log("🗑️ [handleSave] 삭제할 항목 발견:", { fieldKey: key, targetTable, deletedItemIds, }); // 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함 for (const itemId of deletedItemIds) { try { console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`); const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable, context.screenId); if (deleteResult.success) { console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`); } else { console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message); } } catch (deleteError) { console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError); } } } } } // 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터) // formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기 console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData)); console.log("🔎 [handleSave] formData 전체:", context.formData); for (const [fieldKey, fieldValue] of Object.entries(context.formData)) { console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, { type: typeof fieldValue, isArray: Array.isArray(fieldValue), valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue, }); // JSON 문자열인 경우 파싱 let parsedData = fieldValue; if (typeof fieldValue === "string" && fieldValue.startsWith("[")) { try { parsedData = JSON.parse(fieldValue); } catch { continue; } } // 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리 if (!Array.isArray(parsedData) || parsedData.length === 0) continue; const firstItem = parsedData[0]; const repeaterTargetTable = firstItem?._targetTable; // _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리) if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue; console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, { itemCount: parsedData.length, }); // 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등) // "범용_폼_모달" 키에서 공통 필드를 가져옴 const universalFormData = context.formData["범용_폼_모달"] as Record | undefined; const commonFields: Record = {}; if (universalFormData && typeof universalFormData === "object") { // 공통 필드 복사 (내부 메타 필드 제외) for (const [key, value] of Object.entries(universalFormData)) { if (!key.startsWith("_") && !key.endsWith("_numberingRuleId") && value !== undefined && value !== "") { commonFields[key] = value; } } console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields); } // 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출 // 규칙 기반 필터링: 하드코딩 대신 패턴으로 제외할 필드를 정의 for (const [fieldName, value] of Object.entries(context.formData)) { // 제외 규칙 1: comp_로 시작하는 필드 (하위 항목 배열) if (fieldName.startsWith("comp_")) continue; // 제외 규칙 2: _numberingRuleId로 끝나는 필드 (채번 규칙 메타 정보) if (fieldName.endsWith("_numberingRuleId")) continue; // 제외 규칙 3: _로 시작하는 필드 (내부 메타 필드) if (fieldName.startsWith("_")) continue; // 제외 규칙 4: 배열 타입 (하위 항목 데이터) if (Array.isArray(value)) continue; // 제외 규칙 5: 객체 타입 (복잡한 구조 데이터) - null 제외 if (value !== null && typeof value === "object") continue; // 제외 규칙 6: 빈 값 if (value === undefined || value === "" || value === null) continue; // 제외 규칙 7: 이미 commonFields에 있는 필드 (범용 폼 모달에서 가져온 필드) if (fieldName in commonFields) continue; // 위 규칙에 해당하지 않는 단순 값(문자열, 숫자, 날짜 등)은 공통 필드로 전달 commonFields[fieldName] = value; } console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields); for (const item of parsedData) { // 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리) const { _targetTable: _, _isNewItem, _existingRecord: __, _originalItemIds: ___, _deletedItemIds: ____, ...dataToSave } = item; // 🆕 빈 id 필드 제거 (새 항목인 경우) if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) { delete dataToSave.id; } // 🆕 공통 필드 병합 + 사용자 정보 추가 // 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선) // 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함 const dataWithMeta: Record = { ...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터 ...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선! created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, }; try { // 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우 const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined; if (isNewRecord) { // INSERT (새 항목) // id 필드 완전히 제거 (자동 생성되도록) delete dataWithMeta.id; // 빈 문자열 id도 제거 if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) { delete dataWithMeta.id; } console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta); const insertResult = await apiClient.post( `/table-management/tables/${repeaterTargetTable}/add`, dataWithMeta, ); console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data); // 무시된 컬럼이 있으면 경고 출력 if (insertResult.data?.data?.skippedColumns?.length > 0) { console.warn( `⚠️ [${repeaterTargetTable}] 테이블에 존재하지 않는 컬럼이 무시됨:`, insertResult.data.data.skippedColumns, ); } } else if (item.id) { // UPDATE (기존 항목) const originalData = { id: item.id }; const updatedData = { ...dataWithMeta, id: item.id }; console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", { id: item.id, table: repeaterTargetTable, }); const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, { originalData, updatedData, }); console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data); } } catch (err) { const error = err as { response?: { data?: unknown; status?: number }; message?: string }; console.error( `❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`, { status: error.response?.status, data: error.response?.data, message: error.message, fullError: JSON.stringify(error.response?.data, null, 2), }, ); } } } // 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리 const repeatScreenModalKeys = Object.keys(context.formData).filter( (key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations", ); // RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀 const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", "")); // 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용) const repeaterFieldGroupTables: string[] = []; for (const [, fieldValue] of Object.entries(context.formData)) { let parsedData = fieldValue; if (typeof fieldValue === "string" && fieldValue.startsWith("[")) { try { parsedData = JSON.parse(fieldValue); } catch { continue; } } if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) { repeaterFieldGroupTables.push(parsedData[0]._targetTable); } } // 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리 const shouldSkipMainSave = repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName); if (shouldSkipMainSave) { console.log( `⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`, { repeatScreenModalTables, repeaterFieldGroupTables, }, ); saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" }; } else { saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, data: dataWithUserInfo, }); } if (repeatScreenModalKeys.length > 0) { console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys); // 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no) const numberingFields: Record = {}; for (const [fieldKey, value] of Object.entries(context.formData)) { // _numberingRuleId로 끝나는 키가 있으면 해당 필드는 채번 규칙 값 if (context.formData[`${fieldKey}_numberingRuleId`]) { numberingFields[fieldKey] = value; } } console.log("📦 [handleSave] 채번 규칙 필드:", numberingFields); for (const key of repeatScreenModalKeys) { const targetTable = key.replace("_repeatScreenModal_", ""); const rows = context.formData[key] as any[]; if (!Array.isArray(rows) || rows.length === 0) continue; console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows); for (const row of rows) { const { _isNew, _targetTable, id, ...dataToSave } = row; // 사용자 정보 추가 + 채번 규칙 값 병합 const dataWithMeta = { ...dataToSave, ...numberingFields, // 채번 규칙 값 (shipment_plan_no 등) created_by: context.userId, updated_by: context.userId, company_code: context.companyCode, }; try { if (_isNew) { // INSERT console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta); const insertResult = await apiClient.post( `/table-management/tables/${targetTable}/add`, dataWithMeta, ); console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data); } else if (id) { // UPDATE const originalData = { id }; const updatedData = { ...dataWithMeta, id }; console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData }); const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { originalData, updatedData, }); console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data); } } catch (error: any) { console.error(`❌ [handleSave] ${targetTable} 저장 실패:`, error.response?.data || error.message); // 개별 실패는 전체 저장을 중단하지 않음 } } } } // 🆕 v3.9: RepeatScreenModal 집계 저장 처리 const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{ resultField: string; aggregatedValue: number; targetTable: string; targetColumn: string; joinKey: { sourceField: string; targetField: string }; sourceValue: any; }>; if (aggregationConfigs && aggregationConfigs.length > 0) { console.log("📊 [handleSave] 집계 저장 시작:", aggregationConfigs); for (const config of aggregationConfigs) { const { targetTable, targetColumn, joinKey, aggregatedValue, sourceValue } = config; try { const originalData = { [joinKey.targetField]: sourceValue }; const updatedData = { [targetColumn]: aggregatedValue, [joinKey.targetField]: sourceValue, }; console.log( `📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`, ); const updateResult = await apiClient.put(`/table-management/tables/${targetTable}/edit`, { originalData, updatedData, }); console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data); } catch (error: any) { console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message); } } } } if (!saveResult.success) { throw new Error(saveResult.message || "저장에 실패했습니다."); } // 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우) if (config.enableDataflowControl && config.dataflowConfig) { console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig); // 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우) // 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨 const formData: Record = (saveResult.data || context.formData || {}) as Record; let parsedSectionData: any[] = []; // comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기 const compFieldKey = Object.keys(formData).find(key => key.startsWith("comp_") && typeof formData[key] === "string" ); if (compFieldKey) { try { const sectionData = JSON.parse(formData[compFieldKey]); if (Array.isArray(sectionData) && sectionData.length > 0) { // 공통 필드와 섹션 데이터 병합 parsedSectionData = sectionData.map((item: any) => { // 섹션 데이터에서 불필요한 내부 필드 제거 const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item; // 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합 const commonFields: Record = {}; Object.keys(formData).forEach(key => { if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) { commonFields[key] = formData[key]; } }); return { ...commonFields, ...cleanItem }; }); console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}건`, parsedSectionData[0]); } } catch (parseError) { console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError); } } // 저장된 데이터를 context에 추가하여 플로우에 전달 const contextWithSavedData = { ...context, savedData: formData, // 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달 selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData, }; await this.executeAfterSaveControl(config, contextWithSavedData); } } else { throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); } // 테이블과 플로우 새로고침 (모달 닫기 전에 실행) context.onRefresh?.(); context.onFlowRefresh?.(); // 저장 성공 후 이벤트 발생 window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기 window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리 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); } /** * 🆕 렉 구조 컴포넌트 일괄 저장 처리 * 미리보기에서 생성된 위치 데이터를 일괄 INSERT */ private static async handleRackStructureBatchSave( config: ButtonActionConfig, context: ButtonActionContext, locations: any[], rackStructureFieldKey: string = "_rackStructureLocations", ): Promise { const { tableName, screenId, userId, companyCode } = context; console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 일괄 저장 시작:", { locationsCount: locations.length, tableName, screenId, rackStructureFieldKey, }); if (!tableName) { throw new Error("테이블명이 지정되지 않았습니다."); } if (locations.length === 0) { throw new Error("저장할 위치 데이터가 없습니다. 먼저 미리보기를 생성해주세요."); } console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 데이터 예시:", locations[0]); // 저장 전 중복 체크 const firstLocation = locations[0]; const warehouseCode = firstLocation.warehouse_code || firstLocation.warehouse_id || firstLocation.warehouseCode; const floor = firstLocation.floor; const zone = firstLocation.zone; if (warehouseCode && floor && zone) { console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone }); try { const existingResponse = await DynamicFormApi.getTableData(tableName, { filters: { warehouse_code: warehouseCode, floor: floor, zone: zone, }, page: 1, pageSize: 1000, }); // API 응답 구조에 따라 데이터 추출 const responseData = existingResponse.data as any; const existingData = responseData?.data || responseData || []; if (Array.isArray(existingData) && existingData.length > 0) { // 중복되는 위치 확인 const existingSet = new Set(existingData.map((loc: any) => `${loc.row_num}-${loc.level_num}`)); const duplicates = locations.filter((loc) => { const key = `${loc.row_num || loc.rowNum}-${loc.level_num || loc.levelNum}`; return existingSet.has(key); }); if (duplicates.length > 0) { const duplicateInfo = duplicates .slice(0, 5) .map((d) => `${d.row_num || d.rowNum}열 ${d.level_num || d.levelNum}단`) .join(", "); const moreCount = duplicates.length > 5 ? ` 외 ${duplicates.length - 5}개` : ""; alert( `이미 등록된 위치가 있습니다!\n\n중복 위치: ${duplicateInfo}${moreCount}\n\n해당 위치를 제외하거나 기존 데이터를 삭제해주세요.`, ); return false; } } } catch (checkError) { console.warn("⚠️ [handleRackStructureBatchSave] 중복 체크 실패 (저장 계속 진행):", checkError); } } // 각 위치 데이터를 그대로 저장 (렉 구조 컴포넌트에서 이미 테이블 컬럼명으로 생성됨) const recordsToInsert = locations.map((loc) => { // 렉 구조 컴포넌트에서 생성된 데이터를 그대로 사용 // 새로운 형식(스네이크 케이스)과 기존 형식(카멜 케이스) 모두 지원 const record: Record = { // 렉 구조에서 생성된 필드 (이미 테이블 컬럼명과 동일) location_code: loc.location_code || loc.locationCode, location_name: loc.location_name || loc.locationName, row_num: loc.row_num || String(loc.rowNum), level_num: loc.level_num || String(loc.levelNum), // 창고 정보 (렉 구조 컴포넌트에서 전달) - DB 컬럼명은 warehouse_code warehouse_code: loc.warehouse_code || loc.warehouse_id || loc.warehouseCode, warehouse_name: loc.warehouse_name || loc.warehouseName, // 위치 정보 (렉 구조 컴포넌트에서 전달) floor: loc.floor, zone: loc.zone, location_type: loc.location_type || loc.locationType, status: loc.status || "사용", // 사용자 정보 추가 writer: userId, company_code: companyCode, }; return record; }); console.log("🏗️ [handleRackStructureBatchSave] 저장할 레코드 수:", recordsToInsert.length); console.log("🏗️ [handleRackStructureBatchSave] 첫 번째 레코드 예시:", recordsToInsert[0]); // 일괄 INSERT 실행 try { let successCount = 0; let errorCount = 0; const errors: string[] = []; for (let i = 0; i < recordsToInsert.length; i++) { const record = recordsToInsert[i]; try { console.log(`🏗️ [handleRackStructureBatchSave] 저장 중 (${i + 1}/${recordsToInsert.length}):`, record); const result = await DynamicFormApi.saveFormData({ screenId, tableName, data: record, }); console.log(`🏗️ [handleRackStructureBatchSave] API 응답 (${i + 1}):`, result); if (result.success) { successCount++; } else { errorCount++; const errorMsg = result.message || result.error || "알 수 없는 오류"; errors.push(errorMsg); console.error(`❌ [handleRackStructureBatchSave] 저장 실패 (${i + 1}):`, errorMsg); } } catch (error: any) { errorCount++; const errorMsg = error.message || "저장 중 오류 발생"; errors.push(errorMsg); console.error(`❌ [handleRackStructureBatchSave] 예외 발생 (${i + 1}):`, error); } } console.log("🏗️ [handleRackStructureBatchSave] 저장 완료:", { successCount, errorCount, errors: errors.slice(0, 5), // 처음 5개 오류만 로그 }); if (errorCount > 0) { if (successCount > 0) { alert(`${successCount}개 저장 완료, ${errorCount}개 저장 실패\n\n오류: ${errors.slice(0, 3).join("\n")}`); } else { throw new Error(`저장 실패: ${errors[0]}`); } } else { alert(`${successCount}개의 위치가 성공적으로 등록되었습니다.`); } // 성공 후 새로고침 if (context.onRefresh) { context.onRefresh(); } // 모달 닫기 if (context.onClose) { context.onClose(); } return successCount > 0; } catch (error: any) { console.error("🏗️ [handleRackStructureBatchSave] 일괄 저장 오류:", error); throw error; } } /** * 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리 * 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장 * 수정 모드: INSERT/UPDATE/DELETE 지원 * 🆕 섹션별 저장 테이블(targetTable) 지원 추가 */ private static async handleUniversalFormModalTableSectionSave( config: ButtonActionConfig, context: ButtonActionContext, formData: Record, ): Promise<{ handled: boolean; success: boolean }> { const { tableName, screenId } = context; // 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음) const universalFormModalKey = Object.keys(formData).find((key) => { const value = formData[key]; if (!value || typeof value !== "object" || Array.isArray(value)) return false; // _tableSection_ 키가 있는지 확인 return Object.keys(value).some((k) => k.startsWith("_tableSection_")); }); if (!universalFormModalKey) { return { handled: false, success: false }; } console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey); const modalData = formData[universalFormModalKey]; // 🆕 universal-form-modal 컴포넌트 설정 가져오기 // 1. componentConfigs에서 컴포넌트 ID로 찾기 // 2. allComponents에서 columnName으로 찾기 // 3. 화면 레이아웃 API에서 가져오기 let modalComponentConfig = context.componentConfigs?.[universalFormModalKey]; // componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기 if (!modalComponentConfig && context.allComponents) { const modalComponent = context.allComponents.find( (comp: any) => comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey, ); if (modalComponent) { modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig; console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id); } } // 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기 if (!modalComponentConfig && screenId) { try { console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId); const { screenApi } = await import("@/lib/api/screen"); const layoutData = await screenApi.getLayout(screenId); if (layoutData && layoutData.components) { // 레이아웃에서 universal-form-modal 컴포넌트 찾기 const modalLayout = (layoutData.components as any[]).find( (comp) => comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey, ); if (modalLayout) { modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig; console.log( "🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:", modalLayout.componentId, ); } } } catch (error) { console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error); } } const sections: any[] = modalComponentConfig?.sections || []; const saveConfig = modalComponentConfig?.saveConfig || {}; console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", { hasComponentConfig: !!modalComponentConfig, sectionsCount: sections.length, mainTableName: saveConfig.tableName || tableName, sectionSaveModes: saveConfig.sectionSaveModes, sectionDetails: sections.map((s: any) => ({ id: s.id, type: s.type, targetTable: s.tableConfig?.saveConfig?.targetTable, })), }); // _tableSection_ 데이터 추출 const tableSectionData: Record = {}; const commonFieldsData: Record = {}; // 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용) // modalData 내부 또는 최상위 formData에서 찾음 const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || []; for (const [key, value] of Object.entries(modalData)) { if (key.startsWith("_tableSection_")) { const sectionId = key.replace("_tableSection_", ""); tableSectionData[sectionId] = value as any[]; } else if (!key.startsWith("_")) { // _로 시작하지 않는 필드는 공통 필드로 처리 commonFieldsData[key] = value; } } console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", { commonFields: Object.keys(commonFieldsData), tableSections: Object.keys(tableSectionData), tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })), originalGroupedDataCount: originalGroupedData.length, isEditMode: originalGroupedData.length > 0, }); // 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음 const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0); if (!hasTableSectionData && originalGroupedData.length === 0) { console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환"); return { handled: false, success: false }; } // 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가) console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작"); const fieldsWithNumbering: Record = {}; // commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기 for (const [key, value] of Object.entries(modalData)) { if (key.endsWith("_numberingRuleId") && value) { const fieldName = key.replace("_numberingRuleId", ""); fieldsWithNumbering[fieldName] = value as string; console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`); } } // formData에서도 확인 (모달 외부에 있을 수 있음) for (const [key, value] of Object.entries(formData)) { if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) { const fieldName = key.replace("_numberingRuleId", ""); fieldsWithNumbering[fieldName] = value as string; console.log( `🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`, ); } } console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering); // 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가 if (Object.keys(fieldsWithNumbering).length > 0) { console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)"); const { allocateNumberingCode } = await import("@/lib/api/numberingRule"); for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) { try { console.log( `🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`, ); const allocateResult = await allocateNumberingCode(ruleId); if (allocateResult.success && allocateResult.data?.generatedCode) { const newCode = allocateResult.data.generatedCode; console.log( `✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`, ); commonFieldsData[fieldName] = newCode; } else { console.warn( `⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error, ); } } catch (allocateError) { console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError); // 오류 시 기존 값 유지 } } } console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료"); try { // 사용자 정보 추가 if (!context.userId) { throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요."); } const userInfo = { writer: context.userId, created_by: context.userId, updated_by: context.userId, company_code: context.companyCode || "", }; let insertedCount = 0; let updatedCount = 0; let deletedCount = 0; let mainRecordId: number | null = null; // 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만) const hasSeparateTargetTable = sections.some( (s) => s.type === "table" && s.tableConfig?.saveConfig?.targetTable && s.tableConfig.saveConfig.targetTable !== tableName, ); if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) { console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName); const mainRowToSave = { ...commonFieldsData, ...userInfo }; // 메타데이터 제거 Object.keys(mainRowToSave).forEach((key) => { if (key.startsWith("_")) { delete mainRowToSave[key]; } }); console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave); const mainSaveResult = await DynamicFormApi.saveFormData({ screenId: screenId!, tableName: tableName!, data: mainRowToSave, }); if (!mainSaveResult.success) { throw new Error(mainSaveResult.message || "메인 데이터 저장 실패"); } mainRecordId = mainSaveResult.data?.id || null; console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId); } // 각 테이블 섹션 처리 for (const [sectionId, currentItems] of Object.entries(tableSectionData)) { console.log( `🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`, ); // 🆕 해당 섹션의 설정 찾기 const sectionConfig = sections.find((s) => s.id === sectionId); const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable; // 🆕 실제 저장할 테이블 결정 // - targetTable이 있으면 해당 테이블에 저장 // - targetTable이 없으면 메인 테이블에 저장 const saveTableName = targetTableName || tableName!; console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, { targetTableName, saveTableName, isMainTable: saveTableName === tableName, }); // 1️⃣ 신규 품목 INSERT (id가 없는 항목) const newItems = currentItems.filter((item) => !item.id); for (const item of newItems) { const rowToSave = { ...commonFieldsData, ...item, ...userInfo }; // 내부 메타데이터 제거 Object.keys(rowToSave).forEach((key) => { if (key.startsWith("_")) { delete rowToSave[key]; } }); // 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우) if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) { rowToSave[saveConfig.primaryKeyColumn] = mainRecordId; } console.log("➕ [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave }); const saveResult = await DynamicFormApi.saveFormData({ screenId: screenId!, tableName: saveTableName, data: rowToSave, }); if (!saveResult.success) { throw new Error(saveResult.message || "신규 품목 저장 실패"); } insertedCount++; } // 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만) const existingItems = currentItems.filter((item) => item.id); for (const item of existingItems) { const originalItem = originalGroupedData.find((orig) => orig.id === item.id); if (!originalItem) { // 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도 // originalGroupedData 전달이 누락된 경우를 처리 console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`); // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 // item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록 // 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터) const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo }; Object.keys(rowToUpdate).forEach((key) => { if (key.startsWith("_")) { delete rowToUpdate[key]; } }); console.log("📝 [UPDATE 폴백] 저장할 데이터:", { id: item.id, tableName: saveTableName, commonFieldsData, itemFields: Object.keys(item).filter(k => !k.startsWith("_")), rowToUpdate, }); // id를 유지하고 UPDATE 실행 const updateResult = await DynamicFormApi.updateFormData(item.id, { tableName: saveTableName, data: rowToUpdate, }); if (!updateResult.success) { throw new Error(updateResult.message || "품목 수정 실패"); } updatedCount++; continue; } // 변경 사항 확인 (공통 필드 포함) // ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀) const currentDataWithCommon = { ...item, ...commonFieldsData }; const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon); if (hasChanges) { console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`); // 변경된 필드만 추출하여 부분 업데이트 const updateResult = await DynamicFormApi.updateFormDataPartial( item.id, originalItem, currentDataWithCommon, saveTableName, ); if (!updateResult.success) { throw new Error(updateResult.message || "품목 수정 실패"); } updatedCount++; } else { console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`); } } // 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목) // ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지) const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean)); const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id))); for (const deletedItem of deletedItems) { console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`); // screenId 전달하여 제어관리 실행 가능하도록 함 const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName, context.screenId); if (!deleteResult.success) { throw new Error(deleteResult.message || "품목 삭제 실패"); } deletedCount++; } } // 결과 메시지 생성 const resultParts: string[] = []; if (mainRecordId) resultParts.push("메인 데이터 저장"); if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`); if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`); if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`); const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음"; console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`); toast.success(`저장 완료: ${resultMessage}`); // 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행) if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) { const flowId = config.dataflowConfig.flowConfig.flowId; console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId }); try { // 플로우 소스 테이블 조회 const { getFlowSourceTable } = await import("@/lib/api/nodeFlows"); const flowSourceInfo = await getFlowSourceTable(flowId); console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo); if (flowSourceInfo.sourceTable) { // 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기 let controlExecuted = false; for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) { const sectionConfig = sections.find((s: any) => s.id === sectionId); const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName; console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, { sectionTargetTable, flowSourceTable: flowSourceInfo.sourceTable, isMatch: sectionTargetTable === flowSourceInfo.sourceTable, }); // 소스 테이블과 일치하는 섹션만 제어 실행 if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) { console.log( `✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`, ); // 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성 const sourceData = sectionItems.map((item: any) => ({ ...commonFieldsData, ...item, })); console.log( `📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`, sourceData[0], ); // 제어 관리용 컨텍스트 생성 const controlContext: ButtonActionContext = { ...context, selectedRowsData: sourceData, formData: commonFieldsData, }; // 제어 관리 실행 await this.executeAfterSaveControl(config, controlContext); controlExecuted = true; break; // 첫 번째 매칭 섹션만 실행 } } // 매칭되는 섹션이 없으면 메인 테이블 확인 if (!controlExecuted && tableName === flowSourceInfo.sourceTable) { console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행"); const controlContext: ButtonActionContext = { ...context, selectedRowsData: [commonFieldsData], formData: commonFieldsData, }; await this.executeAfterSaveControl(config, controlContext); } } else { console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵"); } } catch (controlError) { console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError); // 제어 관리 실패는 저장 성공에 영향주지 않음 } } // 저장 성공 이벤트 발생 window.dispatchEvent(new CustomEvent("saveSuccess")); window.dispatchEvent(new CustomEvent("refreshTable")); // EditModal 닫기 이벤트 발생 window.dispatchEvent(new CustomEvent("closeEditModal")); return { handled: true, success: true }; } catch (error: any) { console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error); toast.error(error.message || "저장 중 오류가 발생했습니다."); return { handled: true, success: false }; } } /** * 두 객체 간 변경 사항 확인 */ private static checkForChanges(original: Record, current: Record): boolean { // 비교할 필드 목록 (메타데이터 제외) const fieldsToCompare = new Set([ ...Object.keys(original).filter((k) => !k.startsWith("_")), ...Object.keys(current).filter((k) => !k.startsWith("_")), ]); for (const field of fieldsToCompare) { // 시스템 필드는 비교에서 제외 if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) { continue; } const originalValue = original[field]; const currentValue = current[field]; // null/undefined 통일 처리 const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue); const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue); if (normalizedOriginal !== normalizedCurrent) { console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`); return true; } } return false; } /** * 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조) * ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장 */ private static async handleBatchSave( config: ButtonActionConfig, context: ButtonActionContext, selectedItemsKeys: string[], ): Promise { const { formData, tableName, screenId, selectedRowsData, originalData } = context; console.log("🔍 [handleBatchSave] context 확인:", { hasSelectedRowsData: !!selectedRowsData, selectedRowsCount: selectedRowsData?.length || 0, hasOriginalData: !!originalData, originalDataKeys: originalData ? Object.keys(originalData) : [], }); if (!tableName || !screenId) { toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)"); return false; } try { let successCount = 0; let failCount = 0; const errors: string[] = []; // 🆕 부모 화면 데이터 준비 (parentDataMapping용) // selectedRowsData 또는 originalData를 parentData로 사용 const parentData = selectedRowsData?.[0] || originalData || {}; // 🆕 modalDataStore에서 누적된 모든 테이블 데이터 가져오기 // (여러 단계 모달에서 전달된 데이터 접근용) let modalDataStoreRegistry: Record = {}; if (typeof window !== "undefined") { try { // Zustand store에서 데이터 가져오기 const { useModalDataStore } = await import("@/stores/modalDataStore"); modalDataStoreRegistry = useModalDataStore.getState().dataRegistry; } catch (error) { console.warn("⚠️ modalDataStore 로드 실패:", error); } } // 각 테이블의 첫 번째 항목을 modalDataStore로 변환 const modalDataStore: Record = {}; Object.entries(modalDataStoreRegistry).forEach(([key, items]) => { if (Array.isArray(items) && items.length > 0) { // ModalDataItem[] → originalData 추출 modalDataStore[key] = items.map((item) => item.originalData || item); } }); // 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리 for (const key of selectedItemsKeys) { // 🆕 새로운 데이터 구조: ItemData[] with fieldGroups const items = formData[key] as Array<{ id: string; originalData: any; fieldGroups: Record>; }>; // 🆕 이 컴포넌트의 parentDataMapping 설정 가져오기 const componentConfig = context.componentConfigs?.[key]; const parentDataMapping = componentConfig?.parentDataMapping || []; // 🆕 각 품목의 그룹 간 조합(카티션 곱) 생성 for (const item of items) { const groupKeys = Object.keys(item.fieldGroups); // 각 그룹의 항목 배열 가져오기 const groupArrays = groupKeys.map((groupKey) => ({ groupKey, entries: item.fieldGroups[groupKey] || [], })); // 카티션 곱 계산 함수 const cartesianProduct = (arrays: any[][]): any[][] => { if (arrays.length === 0) return [[]]; if (arrays.length === 1) return arrays[0].map((item) => [item]); const [first, ...rest] = arrays; const restProduct = cartesianProduct(rest); return first.flatMap((item) => restProduct.map((combination) => [item, ...combination])); }; // 모든 그룹의 카티션 곱 생성 const entryArrays = groupArrays.map((g) => g.entries); // 🆕 모든 그룹이 비어있는지 확인 const allGroupsEmpty = entryArrays.every((arr) => arr.length === 0); let combinations: any[][]; if (allGroupsEmpty) { // 🆕 모든 그룹이 비어있으면 빈 조합 하나 생성 (품목 기본 정보만으로 저장) console.log("📝 [handleBatchSave] 모든 그룹이 비어있음 - 기본 레코드 생성"); combinations = [[]]; } else { // 빈 그룹을 필터링하여 카티션 곱 계산 (빈 그룹은 무시) const nonEmptyArrays = entryArrays.filter((arr) => arr.length > 0); combinations = nonEmptyArrays.length > 0 ? cartesianProduct(nonEmptyArrays) : [[]]; } // 각 조합을 개별 레코드로 저장 for (let i = 0; i < combinations.length; i++) { const combination = combinations[i]; try { // 🆕 부모 데이터 매핑 적용 const mappedData: any = {}; // 1. parentDataMapping 설정이 있으면 적용 if (parentDataMapping.length > 0) { for (const mapping of parentDataMapping) { let sourceData: any; const sourceTableName = mapping.sourceTable; const selectedItemTable = componentConfig?.sourceTable; if (sourceTableName === selectedItemTable) { sourceData = item.originalData; } else { const tableData = modalDataStore[sourceTableName]; if (tableData && Array.isArray(tableData) && tableData.length > 0) { sourceData = tableData[0]; } else { sourceData = parentData; } } const sourceValue = sourceData[mapping.sourceField]; if (sourceValue !== undefined && sourceValue !== null) { mappedData[mapping.targetField] = sourceValue; } else if (mapping.defaultValue !== undefined) { mappedData[mapping.targetField] = mapping.defaultValue; } } } else { // 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성) if (item.originalData.id) { mappedData.item_id = item.originalData.id; } if (parentData.id || parentData.customer_id) { mappedData.customer_id = parentData.customer_id || parentData.id; } } // 공통 필드 복사 (company_code, currency_code 등) if (item.originalData.company_code && !mappedData.company_code) { mappedData.company_code = item.originalData.company_code; } if (item.originalData.currency_code && !mappedData.currency_code) { mappedData.currency_code = item.originalData.currency_code; } // 원본 데이터로 시작 (매핑된 데이터 사용) let mergedData = { ...mappedData }; // 각 그룹의 항목 데이터를 순차적으로 병합 for (let j = 0; j < combination.length; j++) { const entry = combination[j]; const { id, ...entryData } = entry; // id 제외 mergedData = { ...mergedData, ...entryData }; } // 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록) const { id: _removedId, ...dataWithoutId } = mergedData; // 사용자 정보 추가 if (!context.userId) { throw new Error("사용자 정보를 불러올 수 없습니다."); } const writerValue = context.userId; const companyCodeValue = context.companyCode || ""; const dataWithUserInfo = { ...dataWithoutId, writer: dataWithoutId.writer || writerValue, created_by: writerValue, updated_by: writerValue, company_code: dataWithoutId.company_code || companyCodeValue, }; // INSERT 실행 const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); const saveResult = await DynamicFormApi.saveFormData({ screenId, tableName, data: dataWithUserInfo, }); if (saveResult.success) { successCount++; } else { failCount++; errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`); } } catch (error: any) { failCount++; errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`); } } } } // 결과 토스트 if (failCount === 0) { toast.success(`${successCount}개 항목이 저장되었습니다.`); } else if (successCount === 0) { toast.error(`저장 실패: ${errors.join(", ")}`); return false; } else { toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`); } // 테이블과 플로우 새로고침 context.onRefresh?.(); context.onFlowRefresh?.(); // 저장 성공 후 이벤트 발생 window.dispatchEvent(new CustomEvent("closeEditModal")); window.dispatchEvent(new CustomEvent("saveSuccessInModal")); return true; } catch (error: any) { console.error("배치 저장 오류:", error); toast.error(`저장 오류: ${error.message}`); return false; } } /** * 삭제 액션 처리 */ private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise { const { formData, tableName, screenId, selectedRowsData, flowSelectedData } = context; try { // 플로우 선택 데이터 우선 사용 const 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 }); // screenId 전달하여 제어관리 실행 가능하도록 함 const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName, screenId); 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?.(); // 테이블 새로고침 // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생"); } toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`); return true; } // 단일 삭제 (기존 로직) if (tableName && screenId && formData.id) { console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id }); // 실제 삭제 API 호출 - screenId 전달하여 제어관리 실행 가능하도록 함 const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName, screenId); if (!deleteResult.success) { throw new Error(deleteResult.message || "삭제에 실패했습니다."); } console.log("✅ 단일 삭제 성공:", deleteResult); } else { throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)"); } context.onRefresh?.(); // 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생 window.dispatchEvent(new CustomEvent("refreshTable")); console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)"); toast.success(config.successMessage || "삭제되었습니다."); 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; } /** * 연관 데이터 버튼의 선택 데이터로 모달 열기 * RelatedDataButtons 컴포넌트에서 선택된 버튼 데이터를 모달로 전달 */ private static async handleOpenRelatedModal( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { // 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인) const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId; console.log("🔍 [openRelatedModal] 설정 확인:", { config, relatedModalConfig: config.relatedModalConfig, targetScreenId: config.targetScreenId, finalTargetScreenId: targetScreenId, }); if (!targetScreenId) { console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다."); toast.error("모달 화면 ID가 설정되지 않았습니다."); return false; } // RelatedDataButtons에서 선택된 데이터 가져오기 const relatedData = window.__relatedButtonsSelectedData; console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", { relatedData, selectedItem: relatedData?.selectedItem, config: relatedData?.config, }); if (!relatedData?.selectedItem) { console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다."); toast.warning("먼저 버튼을 선택해주세요."); return false; } const { selectedItem, config: relatedConfig } = relatedData; // 데이터 매핑 적용 const initialData: Record = {}; console.log("🔍 [openRelatedModal] 매핑 설정:", { modalLink: relatedConfig?.modalLink, dataMapping: relatedConfig?.modalLink?.dataMapping, }); if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) { relatedConfig.modalLink.dataMapping.forEach((mapping) => { console.log("🔍 [openRelatedModal] 매핑 처리:", { mapping, sourceField: mapping.sourceField, targetField: mapping.targetField, selectedItemValue: selectedItem.value, selectedItemId: selectedItem.id, rawDataValue: selectedItem.rawData[mapping.sourceField], }); if (mapping.sourceField === "value") { initialData[mapping.targetField] = selectedItem.value; } else if (mapping.sourceField === "id") { initialData[mapping.targetField] = selectedItem.id; } else if (selectedItem.rawData[mapping.sourceField] !== undefined) { initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField]; } }); } else { // 기본 매핑: id를 routing_version_id로 전달 console.log("🔍 [openRelatedModal] 기본 매핑 사용"); initialData["routing_version_id"] = selectedItem.value || selectedItem.id; } console.log("📤 [openRelatedModal] 모달 열기:", { targetScreenId, selectedItem, initialData, }); // 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용) window.dispatchEvent( new CustomEvent("openScreenModal", { detail: { screenId: targetScreenId, title: config.modalTitle, description: config.modalDescription, editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음 onSuccess: () => { // 성공 후 데이터 새로고침 window.dispatchEvent(new CustomEvent("refreshTableData")); }, }, }), ); return true; } /** * 모달 액션 처리 * 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용) */ private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise { // 모달 열기 로직 console.log("모달 열기:", { title: config.modalTitle, size: config.modalSize, targetScreenId: config.targetScreenId, selectedRowsData: context.selectedRowsData, }); 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 selectedData = context.selectedRowsData || []; console.log("📦 [handleModal] 선택된 데이터:", selectedData); console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData); // 전역 모달 상태 업데이트를 위한 이벤트 발생 const modalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, title: config.modalTitle || "화면", description: description, size: config.modalSize || "md", // 선택된 행 데이터 전달 selectedData: selectedData, selectedIds: selectedData.map((row: any) => row.id).filter(Boolean), // 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용) splitPanelParentData: context.splitPanelParentData || {}, }, }); window.dispatchEvent(modalEvent); // 모달 열기는 조용히 처리 (토스트 불필요) } else { console.error("모달로 열 화면이 지정되지 않았습니다."); return false; } return true; } /** * 🆕 데이터를 전달하면서 모달 열기 액션 처리 */ private static async handleOpenModalWithData( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { console.log("📦 데이터와 함께 모달 열기:", { title: config.modalTitle, size: config.modalSize, targetScreenId: config.targetScreenId, dataSourceId: config.dataSourceId, }); // 🆕 1. 현재 화면의 TableList 또는 SplitPanelLayout 자동 감지 let dataSourceId = config.dataSourceId; if (!dataSourceId && context.allComponents) { // TableList 우선 감지 const tableListComponent = context.allComponents.find( (comp: any) => comp.componentType === "table-list" && comp.componentConfig?.tableName, ); if (tableListComponent) { dataSourceId = tableListComponent.componentConfig.tableName; console.log("✨ TableList 자동 감지:", { componentId: tableListComponent.id, tableName: dataSourceId, }); } else { // TableList가 없으면 SplitPanelLayout의 좌측 패널 감지 const splitPanelComponent = context.allComponents.find( (comp: any) => comp.componentType === "split-panel-layout" && comp.componentConfig?.leftPanel?.tableName, ); if (splitPanelComponent) { dataSourceId = splitPanelComponent.componentConfig.leftPanel.tableName; console.log("✨ 분할 패널 좌측 테이블 자동 감지:", { componentId: splitPanelComponent.id, tableName: dataSourceId, }); } } } // 여전히 없으면 context.tableName 또는 "default" 사용 if (!dataSourceId) { dataSourceId = context.tableName || "default"; } // 🆕 2. modalDataStore에서 현재 선택된 데이터 확인 try { const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; const modalData = dataRegistry[dataSourceId] || []; console.log("📊 현재 화면 데이터 확인:", { dataSourceId, count: modalData.length, allKeys: Object.keys(dataRegistry), // 🆕 전체 데이터 키 확인 }); if (modalData.length === 0) { console.warn("⚠️ 선택된 데이터가 없습니다:", dataSourceId); toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요."); return false; } console.log("✅ 모달 데이터 준비 완료:", { currentData: { id: dataSourceId, count: modalData.length }, previousData: Object.entries(dataRegistry) .filter(([key]) => key !== dataSourceId) .map(([key, data]: [string, any]) => ({ id: key, count: data.length })), }); } catch (error) { console.error("❌ 데이터 확인 실패:", error); toast.error("데이터 확인 중 오류가 발생했습니다."); return false; } // 6. 동적 모달 제목 생성 const { useModalDataStore } = await import("@/stores/modalDataStore"); const dataRegistry = useModalDataStore.getState().dataRegistry; let finalTitle = "데이터 입력"; // 🆕 블록 기반 제목 (우선순위 1) if (config.modalTitleBlocks && config.modalTitleBlocks.length > 0) { const titleParts: string[] = []; config.modalTitleBlocks.forEach((block) => { if (block.type === "text") { // 텍스트 블록: 그대로 추가 titleParts.push(block.value); } else if (block.type === "field") { // 필드 블록: 데이터에서 값 가져오기 const tableName = block.tableName; const columnName = block.value; if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; if (value !== undefined && value !== null) { titleParts.push(String(value)); console.log(`✨ 동적 필드: ${tableName}.${columnName} → ${value}`); } else { // 데이터 없으면 라벨 표시 titleParts.push(block.label || columnName); } } else { // 테이블 데이터 없으면 라벨 표시 titleParts.push(block.label || columnName); } } } }); finalTitle = titleParts.join(""); console.log("📋 블록 기반 제목 생성:", finalTitle); } // 기존 방식: {tableName.columnName} 패턴 (우선순위 2) else if (config.modalTitle) { finalTitle = config.modalTitle; if (finalTitle.includes("{")) { const matches = finalTitle.match(/\{([^}]+)\}/g); if (matches) { matches.forEach((match) => { const path = match.slice(1, -1); // {item_info.item_name} → item_info.item_name const [tableName, columnName] = path.split("."); if (tableName && columnName) { const tableData = dataRegistry[tableName]; if (tableData && tableData.length > 0) { const firstItem = tableData[0].originalData || tableData[0]; const value = firstItem[columnName]; if (value !== undefined && value !== null) { finalTitle = finalTitle.replace(match, String(value)); console.log(`✨ 동적 제목: ${match} → ${value}`); } } } }); } } } // 7. 모달 열기 + URL 파라미터로 dataSourceId 전달 if (config.targetScreenId) { // config에 modalDescription이 있으면 우선 사용 let description = config.modalDescription || ""; // config에 없으면 화면 정보에서 가져오기 if (!description) { try { const screenInfo = await screenApi.getScreen(config.targetScreenId); description = screenInfo?.description || ""; } catch (error) { console.warn("화면 설명을 가져오지 못했습니다:", error); } } // 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용) const rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {}; // 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼) const parentData = { ...rawParentData }; if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) { console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings); config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => { if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) { // 타겟 필드에 소스 필드 값 복사 parentData[mapping.targetField] = rawParentData[mapping.sourceField]; console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}: ${rawParentData[mapping.sourceField]}`); } }); } // 🆕 modalDataStore에서 선택된 전체 데이터 가져오기 (RepeatScreenModal에서 사용) const modalData = dataRegistry[dataSourceId] || []; const selectedData = modalData.map((item: any) => item.originalData || item); const selectedIds = selectedData.map((row: any) => row.id).filter(Boolean); console.log("📦 [openModalWithData] 부모 데이터 전달:", { dataSourceId, rawParentData, mappedParentData: parentData, fieldMappings: config.fieldMappings, selectedDataCount: selectedData.length, selectedIds, }); // 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함) const modalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, title: finalTitle, // 🆕 동적 제목 사용 description: description, size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음) splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용) // 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용) selectedData: selectedData, selectedIds: selectedIds, }, }); window.dispatchEvent(modalEvent); // 성공 메시지 (간단하게) toast.success(config.successMessage || "다음 단계로 진행합니다."); return true; } else { console.error("모달로 열 화면이 지정되지 않았습니다."); toast.error("대상 화면이 지정되지 않았습니다."); return false; } } /** * 새 창 액션 처리 */ 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; // 플로우 선택 데이터 우선 사용 const dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; // 선택된 데이터가 없는 경우 if (!dataToEdit || dataToEdit.length === 0) { toast.error("수정할 항목을 선택해주세요."); return false; } // 편집 화면이 설정되지 않은 경우 if (!config.targetScreenId) { toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요."); return false; } if (dataToEdit.length === 1) { // 단일 항목 편집 const rowData = dataToEdit[0]; await this.openEditForm(config, rowData, context); } else { // 다중 항목 편집 - 현재는 단일 편집만 지원 toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요."); return false; } 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: // 기본값: 모달 await this.openEditModal(config, rowData, context); } } /** * 편집 모달 열기 */ private static async openEditModal( config: ButtonActionConfig, rowData: any, context: ButtonActionContext, isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달 ): Promise { const { groupByColumns = [] } = config; // PK 값 추출 (우선순위: id > ID > 첫 번째 필드) let primaryKeyValue: any; if (rowData.id !== undefined && rowData.id !== null) { primaryKeyValue = rowData.id; } else if (rowData.ID !== undefined && rowData.ID !== null) { primaryKeyValue = rowData.ID; } else { primaryKeyValue = Object.values(rowData)[0]; } // 1. config에 editModalDescription이 있으면 우선 사용 let description = config.editModalDescription || ""; // 2. config에 없으면 화면 정보에서 가져오기 let screenInfo: any = null; if (config.targetScreenId) { try { screenInfo = await screenApi.getScreen(config.targetScreenId); if (!description) { description = screenInfo?.description || ""; } } catch (error) { console.warn("화면 설명을 가져오지 못했습니다:", error); } } // 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지) let hasSplitPanel = false; if (config.targetScreenId) { try { const layoutData = await screenApi.getLayout(config.targetScreenId); if (layoutData?.components) { hasSplitPanel = layoutData.components.some( (comp: any) => comp.type === "screen-split-panel" || comp.componentType === "screen-split-panel" || comp.type === "split-panel-layout" || comp.componentType === "split-panel-layout", ); } // console.log("🔍 [openEditModal] 분할 패널 확인:", { // targetScreenId: config.targetScreenId, // hasSplitPanel, // componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [], // }); } catch (error) { console.warn("레이아웃 정보를 가져오지 못했습니다:", error); } } // 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달) if (hasSplitPanel) { console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용"); const screenModalEvent = new CustomEvent("openScreenModal", { detail: { screenId: config.targetScreenId, title: config.editModalTitle || "데이터 수정", description: description, size: config.modalSize || "lg", editData: rowData, // 🆕 수정 데이터 전달 }, }); window.dispatchEvent(screenModalEvent); return; } // 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리) const modalEvent = new CustomEvent("openEditModal", { detail: { screenId: config.targetScreenId, title: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"), description: description, modalSize: config.modalSize || "lg", editData: rowData, isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록 groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 tableName: context.tableName, // 🆕 테이블명 전달 buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등) onSave: () => { 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 async handleCopy(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { const { selectedRowsData, flowSelectedData } = context; // 플로우 선택 데이터 우선 사용 const dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData; console.log("📋 handleCopy - 데이터 소스 확인:", { hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0), flowSelectedDataLength: flowSelectedData?.length || 0, hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0), selectedRowsDataLength: selectedRowsData?.length || 0, dataToCopyLength: dataToCopy?.length || 0, }); // 선택된 데이터가 없는 경우 if (!dataToCopy || dataToCopy.length === 0) { toast.error("복사할 항목을 선택해주세요."); return false; } // 복사 화면이 설정되지 않은 경우 if (!config.targetScreenId) { toast.error("복사 폼 화면이 설정되지 않았습니다. 버튼 설정에서 복사 폼 화면을 선택해주세요."); return false; } console.log(`📋 복사 액션 실행: ${dataToCopy.length}개 항목`, { dataToCopy, targetScreenId: config.targetScreenId, editMode: config.editMode, }); if (dataToCopy.length === 1) { // 단일 항목 복사 const rowData = dataToCopy[0]; console.log("📋 단일 항목 복사:", rowData); console.log("📋 원본 데이터 키 목록:", Object.keys(rowData)); // 복사 시 제거할 필드들 const copiedData = { ...rowData }; const fieldsToRemove = [ // ID 필드 (새 레코드 생성) "id", "ID", // 날짜 필드 (자동 생성) "created_date", "createdDate", "updated_date", "updatedDate", "created_at", "createdAt", "updated_at", "updatedAt", "reg_date", "regDate", "mod_date", "modDate", ]; // 제거할 필드 삭제 fieldsToRemove.forEach((field) => { if (copiedData[field] !== undefined) { delete copiedData[field]; console.log(`🗑️ 필드 제거: ${field}`); } }); // 품목코드 필드 초기화 (여러 가능한 필드명 확인) const itemCodeFields = [ "item_code", "itemCode", "item_no", "itemNo", "item_number", "itemNumber", "품목코드", "품번", "code", ]; // 🆕 화면 설정에서 채번 규칙 가져오기 let screenNumberingRules: Record = {}; if (config.targetScreenId) { try { const { screenApi } = await import("@/lib/api/screen"); const layout = await screenApi.getLayout(config.targetScreenId); // 레이아웃에서 채번 규칙이 설정된 컴포넌트 찾기 const findNumberingRules = (components: any[]): void => { for (const comp of components) { const compConfig = comp.componentConfig || {}; // text-input 컴포넌트의 채번 규칙 확인 if (compConfig.autoGeneration?.type === "numbering_rule" && compConfig.autoGeneration?.options?.numberingRuleId) { const columnName = compConfig.columnName || comp.columnName; if (columnName) { screenNumberingRules[columnName] = compConfig.autoGeneration.options.numberingRuleId; console.log(`📋 화면 설정에서 채번 규칙 발견: ${columnName} → ${compConfig.autoGeneration.options.numberingRuleId}`); } } // 중첩된 컴포넌트 확인 if (comp.children && Array.isArray(comp.children)) { findNumberingRules(comp.children); } } }; if (layout?.components) { findNumberingRules(layout.components); } console.log("📋 화면 설정에서 찾은 채번 규칙:", screenNumberingRules); } catch (error) { console.warn("⚠️ 화면 레이아웃 조회 실패:", error); } } // 품목코드 필드를 찾아서 무조건 공백으로 초기화 let resetFieldName = ""; for (const field of itemCodeFields) { if (copiedData[field] !== undefined) { const originalValue = copiedData[field]; const ruleIdKey = `${field}_numberingRuleId`; // 1순위: 원본 데이터에서 채번 규칙 ID 확인 // 2순위: 화면 설정에서 채번 규칙 ID 확인 const numberingRuleId = rowData[ruleIdKey] || screenNumberingRules[field]; const hasNumberingRule = numberingRuleId !== undefined && numberingRuleId !== null && numberingRuleId !== ""; // 품목코드를 무조건 공백으로 초기화 copiedData[field] = ""; // 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성) if (hasNumberingRule) { copiedData[ruleIdKey] = numberingRuleId; console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`); console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`); } else { console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`); } resetFieldName = field; break; } } // 작성자 정보를 현재 사용자로 변경 const writerFields = ["writer", "creator", "reg_user", "regUser", "created_by", "createdBy"]; writerFields.forEach((field) => { if (copiedData[field] !== undefined && context.userId) { copiedData[field] = context.userId; console.log(`👤 작성자 변경: ${field} = ${context.userId}`); } }); if (resetFieldName) { toast.success("복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다."); } else { console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다."); console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData)); toast.info("복사본이 생성됩니다."); } console.log("📋 복사된 데이터:", copiedData); await this.openCopyForm(config, copiedData, context); } else { // 다중 항목 복사 - 현재는 단일 복사만 지원 toast.error("현재 단일 항목 복사만 지원됩니다. 하나의 항목만 선택해주세요."); return false; } return true; } catch (error: any) { console.error("❌ 복사 액션 실행 중 오류:", error); toast.error(`복사 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`); return false; } } /** * 복사 폼 열기 (단일 항목) */ private static async openCopyForm( config: ButtonActionConfig, rowData: any, context: ButtonActionContext, ): Promise { try { const editMode = config.editMode || "modal"; console.log("📋 openCopyForm 실행:", { editMode, targetScreenId: config.targetScreenId }); switch (editMode) { case "modal": // 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로) console.log("📋 모달로 복사 폼 열기 (INSERT 모드)"); await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true break; case "navigate": // 새 페이지로 이동 console.log("📋 새 페이지로 복사 화면 이동"); this.navigateToCopyScreen(config, rowData, context); break; default: // 기본값: 모달 console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)"); this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true } } catch (error: any) { console.error("❌ openCopyForm 실행 중 오류:", error); throw error; } } /** * 복사 화면으로 네비게이션 */ private static navigateToCopyScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void { const copyUrl = `/screens/${config.targetScreenId}?mode=copy`; console.log("🔄 복사 화면으로 이동:", copyUrl); // 복사할 데이터를 sessionStorage에 저장 sessionStorage.setItem("copyData", JSON.stringify(rowData)); window.location.href = copyUrl; } /** * 닫기 액션 처리 */ 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, }); // 🔥 새로운 버튼 액션 실행 시스템 사용 // flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주 const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig; if (isFlowMode && 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 모드이지만 선택된 플로우 데이터가 없습니다."); toast.error("플로우에서 데이터를 먼저 선택해주세요."); return false; } 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 모드이지만 선택된 행이 없습니다."); toast.error("테이블에서 처리할 항목을 먼저 선택해주세요."); return false; } 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": // 폼 + 테이블 선택 (데이터 병합) // 🔥 각 selectedRowsData 항목에 formData를 병합하여 전달 // 이렇게 해야 메일 발송 시 부모 데이터(상품명 등)와 폼 데이터(수신자 등)가 모두 변수로 사용 가능 if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData = context.selectedRowsData.map((row: any) => ({ ...row, ...(context.formData || {}), })); console.log("🔀 폼 + 테이블 선택 데이터 병합:", { dataCount: sourceData.length, sourceData, }); } else if (context.formData && Object.keys(context.formData).length > 0) { sourceData = [context.formData]; console.log("🔀 폼 데이터만 사용 (선택된 행 없음):", 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) { // 🔥 selectedRowsData가 있으면 formData도 함께 병합 // 모달에서 부모 데이터(selectedRowsData)와 폼 입력(formData)을 모두 사용할 수 있도록 if (context.formData && Object.keys(context.formData).length > 0) { sourceData = context.selectedRowsData.map((row: any) => ({ ...row, ...context.formData, })); dataSourceType = "both"; console.log("📊 [자동] 테이블 선택 + 폼 데이터 병합 사용:", { rowCount: context.selectedRowsData.length, formDataKeys: Object.keys(context.formData), }); } else { 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) * EditModal 등 외부에서도 호출 가능하도록 public으로 변경 * 다중 제어 순차 실행 지원 */ public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise { console.log("🎯 저장 후 제어 실행:", { enableDataflowControl: config.enableDataflowControl, dataflowConfig: config.dataflowConfig, dataflowTiming: config.dataflowTiming, }); // 제어 데이터 소스 결정 let controlDataSource = config.dataflowConfig?.controlDataSource; if (!controlDataSource) { controlDataSource = "form"; // 저장 후에는 기본적으로 form 데이터 사용 } const extendedContext: ExtendedControlContext = { formData: context.formData || {}, selectedRows: context.selectedRows || [], selectedRowsData: context.selectedRowsData || [], controlDataSource, }; // 🔥 다중 제어 지원 (flowControls 배열) const flowControls = config.dataflowConfig?.flowControls || []; if (flowControls.length > 0) { console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}개`); // 순서대로 정렬 const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0)); // 노드 플로우 실행 API const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비: context-data 모드는 배열을 기대함 // 우선순위: selectedRowsData > savedData > formData // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) // - savedData: 저장 API 응답 데이터 // - formData: 폼에 입력된 데이터 let sourceData: any[]; if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData = context.selectedRowsData; console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건"); } else { const savedData = context.savedData || context.formData || {}; sourceData = Array.isArray(savedData) ? savedData : [savedData]; console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건"); } let allSuccess = true; const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = []; for (let i = 0; i < sortedControls.length; i++) { const control = sortedControls[i]; // 유효하지 않은 flowId 스킵 if (!control.flowId || control.flowId <= 0) { console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control); continue; } // executionTiming 체크 (after만 실행) if (control.executionTiming && control.executionTiming !== "after") { console.log( `⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`, control.executionTiming, ); continue; } console.log( `\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`, ); try { const result = await executeNodeFlow(control.flowId, { dataSourceType: controlDataSource, sourceData, context: extendedContext, }); results.push({ flowId: control.flowId, flowName: control.flowName, success: result.success, message: result.message, }); if (result.success) { console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`); } else { console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`); allSuccess = false; // 이전 제어 실패 시 다음 제어 실행 중단 console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단"); break; } } catch (error: any) { console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error); results.push({ flowId: control.flowId, flowName: control.flowName, success: false, message: error.message, }); allSuccess = false; break; } } // 결과 요약 const successCount = results.filter((r) => r.success).length; const failCount = results.filter((r) => !r.success).length; console.log("\n📊 다중 제어 실행 완료:", { total: sortedControls.length, executed: results.length, success: successCount, failed: failCount, }); if (allSuccess) { toast.success(`${successCount}개 제어 실행 완료`); } else { toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`); } return; } // 🔥 기존 단일 제어 실행 (하위 호환성) // dataflowTiming이 'after'가 아니면 실행하지 않음 if (config.dataflowTiming && config.dataflowTiming !== "after") { console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming); return; } // 노드 플로우 방식 실행 (flowConfig가 있는 경우) const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; if (hasFlowConfig) { // executionTiming 체크 const flowTiming = config.dataflowConfig.flowConfig.executionTiming; if (flowTiming && flowTiming !== "after") { console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming); return; } console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig); const { flowId } = config.dataflowConfig.flowConfig; try { // 노드 플로우 실행 API 호출 const { executeNodeFlow } = await import("@/lib/api/nodeFlows"); // 데이터 소스 준비: context-data 모드는 배열을 기대함 // 우선순위: selectedRowsData > savedData > formData // - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함) // - savedData: 저장 API 응답 데이터 // - formData: 폼에 입력된 데이터 let sourceData: any[]; if (context.selectedRowsData && context.selectedRowsData.length > 0) { sourceData = context.selectedRowsData; console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건"); } else { const savedData = context.savedData || context.formData || {}; sourceData = Array.isArray(savedData) ? savedData : [savedData]; console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건"); } // repeat-screen-modal 데이터가 있으면 병합 const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) => key.startsWith("_repeatScreenModal_"), ); if (repeatScreenModalKeys.length > 0) { console.log("📦 repeat-screen-modal 데이터 발견:", repeatScreenModalKeys); } console.log("📦 노드 플로우에 전달할 데이터:", { flowId, dataSourceType: controlDataSource, sourceDataCount: sourceData.length, sourceDataSample: sourceData[0], }); const result = await executeNodeFlow(flowId, { dataSourceType: controlDataSource, sourceData, context: extendedContext, }); if (result.success) { console.log("✅ 저장 후 노드 플로우 실행 완료:", result); toast.success("제어 로직 실행이 완료되었습니다."); } else { console.error("❌ 저장 후 노드 플로우 실행 실패:", result); toast.error("저장은 완료되었으나 제어 실행 중 오류가 발생했습니다."); } } catch (error: any) { console.error("❌ 저장 후 노드 플로우 실행 오류:", error); toast.error(`제어 실행 오류: ${error.message || "알 수 없는 오류"}`); } return; // 노드 플로우 실행 후 종료 } // 관계 기반 제어 실행 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를 찾을 수 없습니다."); } // screenId 전달하여 제어관리 실행 가능하도록 함 const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName, context.screenId); 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 { // 동적 import로 엑셀 유틸리티 로드 const { exportToExcel } = await import("@/lib/utils/excelExport"); let dataToExport: any[] = []; let visibleColumns: string[] | undefined = undefined; let columnLabels: Record | undefined = undefined; // 🆕 마스터-디테일 구조 확인 및 처리 if (context.screenId) { const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); if (relationResponse.success && relationResponse.data) { // 마스터-디테일 구조인 경우 전용 API 사용 console.log("📊 마스터-디테일 엑셀 다운로드:", relationResponse.data); const downloadResponse = await DynamicFormApi.getMasterDetailDownloadData( context.screenId, context.filterConditions ); if (downloadResponse.success && downloadResponse.data) { dataToExport = downloadResponse.data.data; visibleColumns = downloadResponse.data.columns; // 헤더와 컬럼 매핑 columnLabels = {}; downloadResponse.data.columns.forEach((col: string, index: number) => { columnLabels![col] = downloadResponse.data.headers[index] || col; }); console.log(`✅ 마스터-디테일 데이터 조회 완료: ${dataToExport.length}행`); } else { toast.error("마스터-디테일 데이터 조회에 실패했습니다."); return false; } // 마스터-디테일 데이터 변환 및 다운로드 if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { dataToExport = dataToExport.map((row: any) => { const filteredRow: Record = {}; visibleColumns!.forEach((columnName: string) => { const label = columnLabels?.[columnName] || columnName; filteredRow[label] = row[columnName]; }); return filteredRow; }); } // 파일명 생성 let defaultFileName = relationResponse.data.masterTable || "데이터"; if (typeof window !== "undefined") { const menuName = localStorage.getItem("currentMenuName"); if (menuName) defaultFileName = menuName; } const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; await exportToExcel(dataToExport, fileName, sheetName, true); toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`); return true; } } // ✅ 기존 로직: 단일 테이블 처리 if (context.tableName) { const { tableDisplayStore } = await import("@/stores/tableDisplayStore"); const storedData = tableDisplayStore.getTableData(context.tableName); // 필터 조건은 저장소 또는 context에서 가져오기 const filterConditions = storedData?.filterConditions || context.filterConditions; const searchTerm = storedData?.searchTerm || context.searchTerm; try { const { entityJoinApi } = await import("@/lib/api/entityJoin"); const apiParams = { page: 1, size: 10000, // 최대 10,000개 sortBy: context.sortBy || storedData?.sortBy || "id", sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc", search: filterConditions, // ✅ 필터 조건 enableEntityJoin: true, // ✅ Entity 조인 // autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨 }; // 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용 const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams); // 🔒 멀티테넌시 확인 const allData = Array.isArray(response) ? response : response?.data || []; const companyCodesInData = [...new Set(allData.map((row: any) => row.company_code))]; if (companyCodesInData.length > 1) { console.error("❌ 멀티테넌시 위반! 여러 회사의 데이터가 섞여있습니다:", companyCodesInData); } // entityJoinApi는 EntityJoinResponse 또는 data 배열을 반환 if (Array.isArray(response)) { // 배열로 직접 반환된 경우 dataToExport = response; } else if (response && "data" in response) { // EntityJoinResponse 객체인 경우 dataToExport = response.data; } else { console.error("❌ 예상치 못한 응답 형식:", response); toast.error("데이터를 가져오는데 실패했습니다."); return false; } } catch (error) { console.error("엑셀 다운로드: 데이터 조회 실패:", error); toast.error("데이터를 가져오는데 실패했습니다."); return false; } } // 폴백: 폼 데이터 else if (context.formData && Object.keys(context.formData).length > 0) { dataToExport = [context.formData]; } // 테이블명도 없고 폼 데이터도 없으면 에러 else { toast.error("다운로드할 데이터 소스가 없습니다."); return false; } // 배열이 아니면 배열로 변환 if (!Array.isArray(dataToExport)) { if (typeof dataToExport === "object" && dataToExport !== null) { dataToExport = [dataToExport]; } else { toast.error("다운로드할 데이터 형식이 올바르지 않습니다."); return false; } } if (dataToExport.length === 0) { toast.error("다운로드할 데이터가 없습니다."); return false; } // 파일명 생성 (메뉴 이름 우선 사용) let defaultFileName = context.tableName || "데이터"; // localStorage에서 메뉴 이름 가져오기 if (typeof window !== "undefined") { const menuName = localStorage.getItem("currentMenuName"); if (menuName) { defaultFileName = menuName; } } const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`; const sheetName = config.excelSheetName || "Sheet1"; const includeHeaders = config.excelIncludeHeaders !== false; // 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기 // visibleColumns, columnLabels는 함수 상단에서 이미 선언됨 try { // 화면 레이아웃 데이터 가져오기 (별도 API 사용) const { apiClient } = await import("@/lib/api/client"); const layoutResponse = await apiClient.get(`/screen-management/screens/${context.screenId}/layout`); if (layoutResponse.data?.success && layoutResponse.data?.data) { const layoutData = layoutResponse.data.data; // components가 문자열이면 파싱 if (typeof layoutData.components === "string") { layoutData.components = JSON.parse(layoutData.components); } // 테이블 리스트 컴포넌트 찾기 const findTableListComponent = (components: any[]): any => { if (!Array.isArray(components)) return null; for (const comp of components) { // componentType이 'table-list'인지 확인 const isTableList = comp.componentType === "table-list"; // componentConfig 안에서 테이블명 확인 const matchesTable = comp.componentConfig?.selectedTable === context.tableName || comp.componentConfig?.tableName === context.tableName; if (isTableList && matchesTable) { return comp; } if (comp.children && comp.children.length > 0) { const found = findTableListComponent(comp.children); if (found) return found; } } return null; }; const tableListComponent = findTableListComponent(layoutData.components || []); if (tableListComponent && tableListComponent.componentConfig?.columns) { const columns = tableListComponent.componentConfig.columns; // visible이 true인 컬럼만 추출 visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName); // 🎯 column_labels 테이블에서 실제 라벨 가져오기 try { const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, { params: { page: 1, size: 9999 }, }); if (columnsResponse.data?.success && columnsResponse.data?.data) { let columnData = columnsResponse.data.data; // data가 객체이고 columns 필드가 있으면 추출 if (columnData.columns && Array.isArray(columnData.columns)) { columnData = columnData.columns; } if (Array.isArray(columnData)) { columnLabels = {}; // API에서 가져온 라벨로 매핑 columnData.forEach((colData: any) => { const colName = colData.column_name || colData.columnName; // 우선순위: column_label > label > displayName > columnName const labelValue = colData.column_label || colData.label || colData.displayName || colName; if (colName && labelValue) { columnLabels![colName] = labelValue; } }); } } } catch (error) { // 실패 시 컴포넌트 설정의 displayName 사용 columnLabels = {}; columns.forEach((col: any) => { if (col.columnName) { columnLabels![col.columnName] = col.displayName || col.label || col.columnName; } }); } } else { console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다."); } } } catch (error) { console.error("❌ 화면 레이아웃 조회 실패:", error); } // 🎨 카테고리 값들 조회 (한 번만) const categoryMap: Record> = {}; let categoryColumns: string[] = []; // 백엔드에서 카테고리 컬럼 정보 가져오기 if (context.tableName) { try { const { getCategoryColumns, getCategoryValues } = await import("@/lib/api/tableCategoryValue"); const categoryColumnsResponse = await getCategoryColumns(context.tableName); if (categoryColumnsResponse.success && categoryColumnsResponse.data) { // 백엔드에서 정의된 카테고리 컬럼들 categoryColumns = categoryColumnsResponse.data .map((col: any) => col.column_name || col.columnName || col.name) .filter(Boolean); // undefined 제거 // 각 카테고리 컬럼의 값들 조회 for (const columnName of categoryColumns) { try { const valuesResponse = await getCategoryValues(context.tableName, columnName, false); if (valuesResponse.success && valuesResponse.data) { // valueCode → valueLabel 매핑 categoryMap[columnName] = {}; valuesResponse.data.forEach((catValue: any) => { const code = catValue.valueCode || catValue.category_value_id; const label = catValue.valueLabel || catValue.label || code; if (code) { categoryMap[columnName][code] = label; } }); } } catch (error) { console.error(`❌ 카테고리 "${columnName}" 조회 실패:`, error); } } } } catch (error) { console.error("❌ 카테고리 정보 조회 실패:", error); } } // 🎨 컬럼 필터링 및 라벨 적용 (항상 실행) if (visibleColumns && visibleColumns.length > 0 && dataToExport.length > 0) { dataToExport = dataToExport.map((row: any) => { const filteredRow: Record = {}; visibleColumns.forEach((columnName: string) => { // __checkbox__ 컬럼은 제외 if (columnName === "__checkbox__") return; if (columnName in row) { // 라벨 우선 사용, 없으면 컬럼명 사용 const label = columnLabels?.[columnName] || columnName; // 🎯 Entity 조인된 값 우선 사용 let value = row[columnName]; // writer → writer_name 사용 if (columnName === "writer" && row["writer_name"]) { value = row["writer_name"]; } // 다른 엔티티 필드들도 _name 우선 사용 else if (row[`${columnName}_name`]) { value = row[`${columnName}_name`]; } // 카테고리 타입 필드는 라벨로 변환 (백엔드에서 정의된 컬럼만) else if (categoryMap[columnName] && typeof value === "string" && categoryMap[columnName][value]) { value = categoryMap[columnName][value]; } filteredRow[label] = value; } }); return filteredRow; }); } // 최대 행 수 제한 const MAX_ROWS = 10000; if (dataToExport.length > MAX_ROWS) { toast.warning(`최대 ${MAX_ROWS.toLocaleString()}개 행까지만 다운로드됩니다.`); dataToExport = dataToExport.slice(0, MAX_ROWS); } // 엑셀 다운로드 실행 await exportToExcel(dataToExport, fileName, sheetName, includeHeaders); toast.success(`${dataToExport.length}개 행이 다운로드되었습니다.`); 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, screenId: context.screenId, // 채번 설정 디버깅 numberingRuleId: config.excelNumberingRuleId, numberingTargetColumn: config.excelNumberingTargetColumn, afterUploadFlows: config.excelAfterUploadFlows, }); // 🆕 마스터-디테일 구조 확인 (화면에 분할 패널이 있으면 자동 감지) let isMasterDetail = false; let masterDetailRelation: any = null; let masterDetailExcelConfig: any = undefined; // 화면 레이아웃에서 분할 패널 자동 감지 if (context.screenId) { const { DynamicFormApi } = await import("@/lib/api/dynamicForm"); const relationResponse = await DynamicFormApi.getMasterDetailRelation(context.screenId); if (relationResponse.success && relationResponse.data) { isMasterDetail = true; masterDetailRelation = relationResponse.data; // 버튼 설정에서 채번 규칙 등 추가 설정 가져오기 if (config.masterDetailExcel) { masterDetailExcelConfig = { ...config.masterDetailExcel, // 분할 패널에서 감지한 테이블 정보로 덮어쓰기 masterTable: relationResponse.data.masterTable, detailTable: relationResponse.data.detailTable, masterKeyColumn: relationResponse.data.masterKeyColumn, detailFkColumn: relationResponse.data.detailFkColumn, }; } else { // 버튼 설정이 없으면 분할 패널 정보만 사용 masterDetailExcelConfig = { masterTable: relationResponse.data.masterTable, detailTable: relationResponse.data.detailTable, masterKeyColumn: relationResponse.data.masterKeyColumn, detailFkColumn: relationResponse.data.detailFkColumn, simpleMode: true, // 기본값으로 간단 모드 사용 }; } console.log("📊 마스터-디테일 구조 자동 감지:", { masterTable: relationResponse.data.masterTable, detailTable: relationResponse.data.detailTable, masterKeyColumn: relationResponse.data.masterKeyColumn, numberingRuleId: masterDetailExcelConfig?.numberingRuleId, }); } } // 동적 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, // 🆕 마스터-디테일 관련 props screenId: context.screenId, isMasterDetail, masterDetailRelation, masterDetailExcelConfig, // 🆕 단일 테이블 채번 설정 numberingRuleId: config.excelNumberingRuleId, numberingTargetColumn: config.excelNumberingTargetColumn, // 🆕 업로드 후 제어 실행 설정 afterUploadFlows: config.excelAfterUploadFlows, 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"); toast.loading("영향받는 데이터 검색 중...", { duration: Infinity }); const previewResponse = await apiClient.post("/code-merge/preview-by-value", { oldValue, }); toast.dismiss(); if (previewResponse.data.success) { const preview = previewResponse.data.data; const totalRows = preview.totalAffectedRows; // 상세 정보 생성 const detailList = preview.preview .map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`) .join("\n"); const confirmMerge = confirm( "코드 병합 확인\n\n" + `${oldValue} → ${newValue}\n\n` + "영향받는 데이터:\n" + `- 테이블/컬럼 수: ${preview.preview.length}개\n` + `- 총 행 수: ${totalRows}개\n\n` + (preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") + "모든 테이블에서 해당 값이 변경됩니다.\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-by-value", { oldValue, newValue, }); toast.dismiss(); if (response.data.success) { const data = response.data.data; // 변경된 테이블/컬럼 목록 생성 const changedList = data.affectedData .map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`) .join(", "); toast.success( `코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`, ); console.log("코드 병합 결과:", data.affectedData); // 화면 새로고침 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 trackingIntervalId: NodeJS.Timeout | null = null; private static currentTripId: string | null = null; private static trackingContext: ButtonActionContext | null = null; private static trackingConfig: ButtonActionConfig | null = null; /** * 연속 위치 추적 시작 */ private static async handleTrackingStart(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("🚀 [handleTrackingStart] 위치 추적 시작:", { config, context }); // 이미 추적 중인지 확인 if (this.trackingIntervalId) { toast.warning("이미 위치 추적이 진행 중입니다."); return false; } // 위치 권한 확인 if (!navigator.geolocation) { toast.error("이 브라우저는 위치 정보를 지원하지 않습니다."); return false; } // Trip ID 생성 const tripId = config.trackingAutoGenerateTripId !== false ? `TRIP_${Date.now()}_${context.userId || "unknown"}` : context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`; this.currentTripId = tripId; this.trackingContext = context; this.trackingConfig = config; // 출발지/도착지 정보 const departure = context.formData?.[config.trackingDepartureField || "departure"] || null; const arrival = context.formData?.[config.trackingArrivalField || "arrival"] || null; const departureName = context.formData?.["departure_name"] || null; const destinationName = context.formData?.["destination_name"] || null; const vehicleId = context.formData?.[config.trackingVehicleIdField || "vehicle_id"] || null; console.log("📍 [handleTrackingStart] 운행 정보:", { tripId, departure, arrival, departureName, destinationName, vehicleId, }); // 상태 변경 (vehicles 테이블 등) if (config.trackingStatusOnStart && config.trackingStatusField) { try { const { apiClient } = await import("@/lib/api/client"); const statusTableName = config.trackingStatusTableName || context.tableName; const keyField = config.trackingStatusKeyField || "user_id"; const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context); if (keyValue) { // 상태 업데이트 await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, updateField: config.trackingStatusField, updateValue: config.trackingStatusOnStart, }); console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart); // 🆕 출발지/도착지도 vehicles 테이블에 저장 if (departure) { try { await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, updateField: "departure", updateValue: departure, }); console.log("✅ 출발지 저장 완료:", departure); } catch { // 컬럼이 없으면 무시 } } if (arrival) { try { await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, updateField: "arrival", updateValue: arrival, }); console.log("✅ 도착지 저장 완료:", arrival); } catch { // 컬럼이 없으면 무시 } } } } catch (statusError) { console.warn("⚠️ 상태 변경 실패:", statusError); } } // 첫 번째 위치 저장 await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId); // 주기적 위치 저장 시작 const interval = config.trackingInterval || 10000; // 기본 10초 this.trackingIntervalId = setInterval(async () => { await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId); }, interval); toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`); // 추적 시작 이벤트 발생 (UI 업데이트용) window.dispatchEvent( new CustomEvent("trackingStarted", { detail: { tripId, interval }, }), ); return true; } catch (error: any) { console.error("❌ 위치 추적 시작 실패:", error); toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다."); return false; } } /** * 연속 위치 추적 종료 */ private static async handleTrackingStop(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context }); // 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정) const isTrackingActive = !!this.trackingIntervalId; if (!isTrackingActive) { // 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원) console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행"); } else { // 타이머 정리 (추적 중인 경우에만) clearInterval(this.trackingIntervalId); this.trackingIntervalId = null; } const tripId = this.currentTripId; // 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용) let dbDeparture: string | null = null; let dbArrival: string | null = null; let dbVehicleId: string | null = null; const userId = context.userId || this.trackingUserId; if (userId) { try { const { apiClient } = await import("@/lib/api/client"); const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; // DB에서 현재 차량 정보 조회 const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, { page: 1, size: 1, search: { [keyField]: userId }, autoFilter: true, }); const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; if (vehicleData) { dbDeparture = vehicleData.departure || null; dbArrival = vehicleData.arrival || null; dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null; console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId }); } } catch (dbError) { console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError); } } // 마지막 위치 저장 (추적 중이었던 경우에만) if (isTrackingActive) { // DB 값 우선, 없으면 formData 사용 const departure = dbDeparture || this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; const arrival = dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; const vehicleId = dbVehicleId || this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; await this.saveLocationToHistory( tripId, departure, arrival, departureName, destinationName, vehicleId, "completed", ); } // 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만) if (isTrackingActive && tripId) { try { const tripStats = await this.calculateTripStats(tripId); console.log("📊 운행 통계:", tripStats); // 운행 통계를 두 테이블에 저장 if (tripStats) { const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m const timeMinutes = tripStats.totalTimeMinutes; const userId = this.trackingUserId || context.userId; console.log("💾 운행 통계 DB 저장 시도:", { tripId, userId, distanceMeters, timeMinutes, startTime: tripStats.startTime, endTime: tripStats.endTime, }); const { apiClient } = await import("@/lib/api/client"); // 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용) try { const lastRecordResponse = await apiClient.post( "/table-management/tables/vehicle_location_history/data", { page: 1, size: 1, search: { trip_id: tripId }, sortBy: "recorded_at", sortOrder: "desc", autoFilter: true, }, ); const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || []; if (lastRecordData.length > 0) { const lastRecordId = lastRecordData[0].id; console.log("📍 마지막 레코드 ID:", lastRecordId); const historyUpdates = [ { field: "trip_distance", value: distanceMeters }, { field: "trip_time", value: timeMinutes }, { field: "trip_start", value: tripStats.startTime }, { field: "trip_end", value: tripStats.endTime }, ]; for (const update of historyUpdates) { await apiClient.put("/dynamic-form/update-field", { tableName: "vehicle_location_history", keyField: "id", keyValue: lastRecordId, updateField: update.field, updateValue: update.value, }); } console.log("✅ vehicle_location_history 통계 저장 완료"); } else { console.warn("⚠️ trip_id에 해당하는 레코드를 찾을 수 없음:", tripId); } } catch (historyError) { console.warn("⚠️ vehicle_location_history 저장 실패:", historyError); } // 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용) if (userId) { try { const vehicleUpdates = [ { field: "last_trip_distance", value: distanceMeters }, { field: "last_trip_time", value: timeMinutes }, { field: "last_trip_start", value: tripStats.startTime }, { field: "last_trip_end", value: tripStats.endTime }, ]; for (const update of vehicleUpdates) { await apiClient.put("/dynamic-form/update-field", { tableName: "vehicles", keyField: "user_id", keyValue: userId, updateField: update.field, updateValue: update.value, }); } console.log("✅ vehicles 테이블 통계 업데이트 완료"); } catch (vehicleError) { console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError); } } // 이벤트로 통계 전달 (UI에서 표시용) window.dispatchEvent( new CustomEvent("tripCompleted", { detail: { tripId, totalDistanceKm: tripStats.totalDistanceKm, totalTimeMinutes: tripStats.totalTimeMinutes, startTime: tripStats.startTime, endTime: tripStats.endTime, }, }), ); toast.success( `운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`, ); } } catch (statsError) { console.warn("⚠️ 운행 통계 계산 실패:", statsError); } } // 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용 const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config; const effectiveContext = context.userId ? context : this.trackingContext || context; if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) { try { const { apiClient } = await import("@/lib/api/client"); const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName; const keyField = effectiveConfig.trackingStatusKeyField || "user_id"; const keyValue = resolveSpecialKeyword( effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext, ); if (keyValue) { await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, updateField: effectiveConfig.trackingStatusField, updateValue: effectiveConfig.trackingStatusOnStop, }); console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop); // 🆕 운행 종료 시 vehicles 테이블의 출발지/도착지/위도/경도를 null로 초기화 const fieldsToReset = ["departure", "arrival", "latitude", "longitude"]; for (const field of fieldsToReset) { try { await apiClient.put("/dynamic-form/update-field", { tableName: statusTableName, keyField: keyField, keyValue: keyValue, updateField: field, updateValue: null, }); } catch { // 컬럼이 없으면 무시 } } console.log("✅ 출발지/도착지/위도/경도 초기화 완료"); } } catch (statusError) { console.warn("⚠️ 상태 변경 실패:", statusError); } } // 컨텍스트 정리 this.currentTripId = null; this.trackingContext = null; this.trackingConfig = null; toast.success(config.successMessage || "위치 추적이 종료되었습니다."); // 추적 종료 이벤트 발생 (UI 업데이트용) window.dispatchEvent( new CustomEvent("trackingStopped", { detail: { tripId }, }), ); // 화면 새로고침 context.onRefresh?.(); return true; } catch (error: any) { console.error("❌ 위치 추적 종료 실패:", error); toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다."); return false; } } /** * 운행 통계 계산 (거리, 시간) */ private static async calculateTripStats(tripId: string): Promise<{ totalDistanceKm: number; totalTimeMinutes: number; startTime: string | null; endTime: string | null; } | null> { try { // vehicle_location_history에서 해당 trip의 모든 위치 조회 const { apiClient } = await import("@/lib/api/client"); const response = await apiClient.post("/table-management/tables/vehicle_location_history/data", { page: 1, size: 10000, search: { trip_id: tripId }, sortBy: "recorded_at", sortOrder: "asc", }); if (!response.data?.success) { console.log("📊 통계 계산: API 응답 실패"); return null; } // 응답 형식: data.data.data 또는 data.data.rows const rows = response.data?.data?.data || response.data?.data?.rows || []; if (!rows.length) { console.log("📊 통계 계산: 데이터 없음"); return null; } const locations = rows; console.log(`📊 통계 계산: ${locations.length}개 위치 데이터`); // 시간 계산 const startTime = locations[0].recorded_at; const endTime = locations[locations.length - 1].recorded_at; const totalTimeMs = new Date(endTime).getTime() - new Date(startTime).getTime(); const totalTimeMinutes = Math.round(totalTimeMs / 60000); // 거리 계산 (Haversine 공식) let totalDistanceM = 0; for (let i = 1; i < locations.length; i++) { const prev = locations[i - 1]; const curr = locations[i]; if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) { const distance = this.calculateDistance( parseFloat(prev.latitude), parseFloat(prev.longitude), parseFloat(curr.latitude), parseFloat(curr.longitude), ); totalDistanceM += distance; } } const totalDistanceKm = totalDistanceM / 1000; console.log("📊 운행 통계 결과:", { tripId, totalDistanceKm, totalTimeMinutes, startTime, endTime, pointCount: locations.length, }); return { totalDistanceKm, totalTimeMinutes, startTime, endTime, }; } catch (error) { console.error("❌ 운행 통계 계산 오류:", error); return null; } } /** * 두 좌표 간 거리 계산 (Haversine 공식, 미터 단위) */ private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number { const R = 6371000; // 지구 반경 (미터) const dLat = ((lat2 - lat1) * Math.PI) / 180; const dLon = ((lon2 - lon1) * Math.PI) / 180; const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2); const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return R * c; } /** * 위치 이력 테이블에 저장 (내부 헬퍼) * + vehicles 테이블의 latitude/longitude도 함께 업데이트 */ private static async saveLocationToHistory( tripId: string | null, departure: string | null, arrival: string | null, departureName: string | null, destinationName: string | null, vehicleId: number | null, tripStatus: string = "active", ): Promise { return new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition( async (position) => { try { const { apiClient } = await import("@/lib/api/client"); const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords; const locationData = { latitude, longitude, accuracy, altitude, speed, heading, tripId, tripStatus, departure, arrival, departureName, destinationName, recordedAt: new Date(position.timestamp).toISOString(), vehicleId, }; console.log("📍 [saveLocationToHistory] 위치 저장:", locationData); // 1. vehicle_location_history에 저장 const response = await apiClient.post("/dynamic-form/location-history", locationData); if (response.data?.success) { console.log("✅ 위치 이력 저장 성공:", response.data.data); } else { console.warn("⚠️ 위치 이력 저장 실패:", response.data); } // 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영) if (this.trackingContext && this.trackingConfig) { const keyField = this.trackingConfig.trackingStatusKeyField || "user_id"; const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__"; const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext); const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles"; if (keyValue) { try { // latitude 업데이트 await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, updateField: "latitude", updateValue: latitude, }); // longitude 업데이트 await apiClient.put("/dynamic-form/update-field", { tableName: vehiclesTableName, keyField, keyValue, updateField: "longitude", updateValue: longitude, }); console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`); } catch (vehicleUpdateError) { // 컬럼이 없으면 조용히 무시 console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError); } } } resolve(); } catch (error) { console.error("❌ 위치 이력 저장 오류:", error); reject(error); } }, (error) => { console.error("❌ 위치 획득 실패:", error.message); reject(error); }, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, }, ); }); } /** * 현재 추적 상태 확인 (외부에서 호출 가능) */ static isTracking(): boolean { return this.trackingIntervalId !== null; } /** * 현재 Trip ID 가져오기 (외부에서 호출 가능) */ static getCurrentTripId(): string | null { return this.currentTripId; } /** * 데이터 전달 액션 처리 (분할 패널에서 좌측 → 우측 데이터 전달) */ private static async handleTransferData(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📤 [handleTransferData] 데이터 전달 시작:", { config, context }); // 선택된 행 데이터 확인 const selectedRows = context.selectedRowsData || context.flowSelectedData || []; if (!selectedRows || selectedRows.length === 0) { toast.error("전달할 데이터를 선택해주세요."); return false; } console.log("📤 [handleTransferData] 선택된 데이터:", selectedRows); // dataTransfer 설정 확인 const dataTransfer = config.dataTransfer; if (!dataTransfer) { // dataTransfer 설정이 없으면 기본 동작: 전역 이벤트로 데이터 전달 console.log("📤 [handleTransferData] dataTransfer 설정 없음 - 전역 이벤트 발생"); const transferEvent = new CustomEvent("splitPanelDataTransfer", { detail: { data: selectedRows, mode: "append", sourcePosition: "left", }, }); window.dispatchEvent(transferEvent); toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } // dataTransfer 설정이 있는 경우 const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer; if (targetType === "component" && targetComponentId) { // 같은 화면 내 컴포넌트로 전달 console.log("📤 [handleTransferData] 컴포넌트로 전달:", targetComponentId); const transferEvent = new CustomEvent("componentDataTransfer", { detail: { targetComponentId, data: selectedRows, mappingRules, mode: receiveMode || "append", }, }); window.dispatchEvent(transferEvent); toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } else if (targetType === "screen" && targetScreenId) { // 다른 화면으로 전달 (분할 패널 등) console.log("📤 [handleTransferData] 화면으로 전달:", targetScreenId); const transferEvent = new CustomEvent("screenDataTransfer", { detail: { targetScreenId, data: selectedRows, mappingRules, mode: receiveMode || "append", }, }); window.dispatchEvent(transferEvent); toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } else { // 기본: 분할 패널 데이터 전달 이벤트 console.log("📤 [handleTransferData] 기본 분할 패널 전달"); const transferEvent = new CustomEvent("splitPanelDataTransfer", { detail: { data: selectedRows, mappingRules, mode: receiveMode || "append", sourcePosition: "left", }, }); window.dispatchEvent(transferEvent); toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`); return true; } } catch (error: any) { console.error("❌ 데이터 전달 실패:", error); toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); return false; } } // 공차 추적용 watchId 저장 private static emptyVehicleWatchId: number | null = null; private static emptyVehicleTripId: string | null = null; /** * 공차등록 액션 처리 * - 위치 수집 + 상태 변경 (예: status → inactive) * - 연속 위치 추적 시작 (vehicle_location_history에 저장) */ private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("📍 공차등록 액션 실행:", { config, context }); // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { toast.error("이 브라우저는 위치정보를 지원하지 않습니다."); return false; } // 위도/경도 저장 필드 확인 const latField = config.geolocationLatField || "latitude"; const lngField = config.geolocationLngField || "longitude"; // 로딩 토스트 표시 const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); // Geolocation 옵션 설정 const options: PositionOptions = { enableHighAccuracy: config.geolocationHighAccuracy !== false, timeout: config.geolocationTimeout || 10000, maximumAge: config.geolocationMaxAge || 0, }; // 위치 정보 가져오기 const position = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, options); }); toast.dismiss(loadingToastId); const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; const timestamp = new Date(position.timestamp); console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy }); // 폼 데이터 업데이트 const updates: Record = { [latField]: latitude, [lngField]: longitude, }; if (config.geolocationAccuracyField && accuracy !== null) { updates[config.geolocationAccuracyField] = accuracy; } if (config.geolocationTimestampField) { updates[config.geolocationTimestampField] = timestamp.toISOString(); } // onFormDataChange로 폼 업데이트 if (context.onFormDataChange) { Object.entries(updates).forEach(([field, value]) => { context.onFormDataChange!(field, value); }); } // 🆕 자동 저장 옵션이 활성화된 경우 DB UPDATE if (config.geolocationAutoSave) { const keyField = config.geolocationKeyField || "user_id"; const keySourceField = config.geolocationKeySourceField || "__userId__"; const keyValue = resolveSpecialKeyword(keySourceField, context); const targetTableName = config.geolocationTableName || context.tableName; if (keyValue && targetTableName) { try { const { apiClient } = await import("@/lib/api/client"); // 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프) const fieldsToUpdate = { ...updates }; // formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만) if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure; if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival; // 추가 필드 변경 (status 등) if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) { fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue; } console.log("📍 DB UPDATE 시작:", { targetTableName, keyField, keyValue, fieldsToUpdate }); // 각 필드를 개별적으로 UPDATE (에러 무시) let successCount = 0; for (const [field, value] of Object.entries(fieldsToUpdate)) { try { const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField, keyValue, updateField: field, updateValue: value, }); if (response.data?.success) { successCount++; } } catch { // 컬럼이 없으면 조용히 무시 (에러 로그 안 찍음) } } console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`); // 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록) if (config.emptyVehicleTracking !== false) { await this.startEmptyVehicleTracking(config, context, { latitude, longitude, accuracy, speed, heading, altitude, }); } toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다."); } catch (saveError) { console.error("❌ 위치정보 자동 저장 실패:", saveError); toast.error("위치 정보 저장에 실패했습니다."); return false; } } else { console.warn("⚠️ 키 값 또는 테이블명이 없어서 자동 저장을 건너뜁니다:", { keyValue, targetTableName }); toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`); } } else { // 자동 저장 없이 성공 메시지만 toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`); } return true; } catch (error: any) { console.error("❌ 공차등록 실패:", error); toast.dismiss(); // GeolocationPositionError 처리 if (error.code) { switch (error.code) { case 1: // PERMISSION_DENIED toast.error("위치 정보 접근이 거부되었습니다.\n브라우저 설정에서 위치 권한을 허용해주세요."); break; case 2: // POSITION_UNAVAILABLE toast.error("위치 정보를 사용할 수 없습니다.\nGPS 신호를 확인해주세요."); break; case 3: // TIMEOUT toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요."); break; default: toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); } } else { toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다."); } return false; } } /** * 공차 상태에서 연속 위치 추적 시작 */ private static async startEmptyVehicleTracking( config: ButtonActionConfig, context: ButtonActionContext, initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null; }, ): Promise { try { // 기존 추적이 있으면 중지 if (this.emptyVehicleWatchId !== null) { navigator.geolocation.clearWatch(this.emptyVehicleWatchId); this.emptyVehicleWatchId = null; } const { apiClient } = await import("@/lib/api/client"); // Trip ID 생성 (공차용) const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`; this.emptyVehicleTripId = tripId; const userId = context.userId || ""; const companyCode = context.companyCode || ""; const departure = context.formData?.departure || ""; const arrival = context.formData?.arrival || ""; const departureName = context.formData?.departure_name || ""; const destinationName = context.formData?.destination_name || ""; // 시작 위치 기록 try { await apiClient.post("/dynamic-form/location-history", { tripId, userId, latitude: initialPosition.latitude, longitude: initialPosition.longitude, accuracy: initialPosition.accuracy, speed: initialPosition.speed, heading: initialPosition.heading, altitude: initialPosition.altitude, tripStatus: "empty_start", // 공차 시작 departure, arrival, departureName, destinationName, companyCode, }); console.log("📍 공차 시작 위치 기록 완료:", tripId); } catch (err) { console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err); } // 추적 간격 (기본 10초) const trackingInterval = config.emptyVehicleTrackingInterval || 10000; // watchPosition으로 연속 추적 this.emptyVehicleWatchId = navigator.geolocation.watchPosition( async (position) => { const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords; try { await apiClient.post("/dynamic-form/location-history", { tripId: this.emptyVehicleTripId, userId, latitude, longitude, accuracy, speed, heading, altitude, tripStatus: "empty_tracking", // 공차 추적 중 departure, arrival, departureName, destinationName, companyCode, }); console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) }); } catch (err) { console.warn("⚠️ 공차 위치 기록 실패:", err); } }, (error) => { console.error("❌ 공차 위치 추적 오류:", error.message); }, { enableHighAccuracy: true, timeout: trackingInterval, maximumAge: 0, }, ); console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId }); } catch (error) { console.error("❌ 공차 위치 추적 시작 실패:", error); } } /** * 공차 위치 추적 중지 (운행 전환 시 호출) */ public static stopEmptyVehicleTracking(): void { if (this.emptyVehicleWatchId !== null) { navigator.geolocation.clearWatch(this.emptyVehicleWatchId); console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId }); this.emptyVehicleWatchId = null; this.emptyVehicleTripId = null; } } /** * 현재 공차 추적 Trip ID 반환 */ public static getEmptyVehicleTripId(): string | null { return this.emptyVehicleTripId; } /** * 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지) */ private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("🔄 필드 값 교환 액션 실행:", { config, context }); const { formData, onFormDataChange } = context; // 교환할 필드 확인 const fieldA = config.swapFieldA; const fieldB = config.swapFieldB; if (!fieldA || !fieldB) { toast.error("교환할 필드가 설정되지 않았습니다."); return false; } // 현재 값 가져오기 const valueA = formData?.[fieldA]; const valueB = formData?.[fieldB]; console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB }); // 값 교환 if (onFormDataChange) { onFormDataChange(fieldA, valueB); onFormDataChange(fieldB, valueA); } // 관련 필드도 함께 교환 (예: 위도/경도) if (config.swapRelatedFields && config.swapRelatedFields.length > 0) { for (const related of config.swapRelatedFields) { const relatedValueA = formData?.[related.fieldA]; const relatedValueB = formData?.[related.fieldB]; if (onFormDataChange) { onFormDataChange(related.fieldA, relatedValueB); onFormDataChange(related.fieldB, relatedValueA); } } } console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA }); toast.success(config.successMessage || "값이 교환되었습니다."); return true; } catch (error) { console.error("❌ 필드 값 교환 오류:", error); toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다."); return false; } } /** * 즉시 저장 (Quick Insert) 액션 처리 * 화면에서 선택한 데이터를 특정 테이블에 즉시 저장 */ private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise { try { console.log("⚡ Quick Insert 액션 실행:", { config, context }); const quickInsertConfig = config.quickInsertConfig; if (!quickInsertConfig?.targetTable) { toast.error("대상 테이블이 설정되지 않았습니다."); return false; } // ✅ allComponents가 있으면 기존 필수 항목 검증 수행 if (context.allComponents && context.allComponents.length > 0) { console.log("🔍 [handleQuickInsert] 필수 항목 검증 시작:", { hasAllComponents: !!context.allComponents, allComponentsLength: context.allComponents?.length || 0, }); const requiredValidation = this.validateRequiredFields(context); if (!requiredValidation.isValid) { console.log("❌ [handleQuickInsert] 필수 항목 누락:", requiredValidation.missingFields); toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`); return false; } console.log("✅ [handleQuickInsert] 필수 항목 검증 통과"); } // ✅ quickInsert 전용 검증: component 타입 매핑에서 값이 비어있는지 확인 const mappingsForValidation = quickInsertConfig.columnMappings || []; const missingMappingFields: string[] = []; for (const mapping of mappingsForValidation) { // component 타입 매핑은 필수 입력으로 간주 if (mapping.sourceType === "component" && mapping.sourceComponentId) { let value: any = undefined; // 값 가져오기 (formData에서) if (mapping.sourceColumnName) { value = context.formData?.[mapping.sourceColumnName]; } if (value === undefined || value === null) { value = context.formData?.[mapping.sourceComponentId]; } // allComponents에서 컴포넌트 찾아서 columnName으로 시도 if ((value === undefined || value === null) && context.allComponents) { const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); if (comp?.columnName) { value = context.formData?.[comp.columnName]; } } // targetColumn으로 폴백 if ((value === undefined || value === null) && mapping.targetColumn) { value = context.formData?.[mapping.targetColumn]; } // 값이 비어있으면 필수 누락으로 처리 if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) { console.log("❌ [handleQuickInsert] component 매핑 값 누락:", { targetColumn: mapping.targetColumn, sourceComponentId: mapping.sourceComponentId, sourceColumnName: mapping.sourceColumnName, value, }); missingMappingFields.push(mapping.targetColumn); } } } if (missingMappingFields.length > 0) { console.log("❌ [handleQuickInsert] 필수 입력 항목 누락:", missingMappingFields); toast.error(`다음 항목을 입력해주세요: ${missingMappingFields.join(", ")}`); return false; } console.log("✅ [handleQuickInsert] quickInsert 매핑 검증 통과"); const { formData, splitPanelContext, userId, userName, companyCode } = context; console.log("⚡ Quick Insert 상세 정보:", { targetTable: quickInsertConfig.targetTable, columnMappings: quickInsertConfig.columnMappings, formData: formData, formDataKeys: Object.keys(formData || {}), splitPanelContext: splitPanelContext, selectedLeftData: splitPanelContext?.selectedLeftData, allComponents: context.allComponents, userId, userName, companyCode, }); // 컬럼 매핑에 따라 저장할 데이터 구성 const insertData: Record = {}; const columnMappings = quickInsertConfig.columnMappings || []; for (const mapping of columnMappings) { console.log("📍 매핑 처리 시작:", mapping); if (!mapping.targetColumn) { console.log("📍 targetColumn 없음, 스킵"); continue; } let value: any = undefined; switch (mapping.sourceType) { case "component": console.log("📍 component 타입 처리:", { sourceComponentId: mapping.sourceComponentId, sourceColumnName: mapping.sourceColumnName, targetColumn: mapping.targetColumn, }); // 컴포넌트의 현재 값 if (mapping.sourceComponentId) { // 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법) if (mapping.sourceColumnName) { value = formData?.[mapping.sourceColumnName]; console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`); } // 2. 없으면 컴포넌트 ID로 직접 찾기 if (value === undefined) { value = formData?.[mapping.sourceComponentId]; console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`); } // 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도 if (value === undefined && context.allComponents) { const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId); console.log("📍 방법3 찾은 컴포넌트:", comp); if (comp?.columnName) { value = formData?.[comp.columnName]; console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`); } } // 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백) if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) { value = formData[mapping.targetColumn]; console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`); } // 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅 if (value === undefined) { console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {})); } // sourceColumn이 지정된 경우 해당 속성 추출 if (mapping.sourceColumn && value && typeof value === "object") { value = value[mapping.sourceColumn]; console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`); } } break; case "leftPanel": console.log("📍 leftPanel 타입 처리:", { sourceColumn: mapping.sourceColumn, selectedLeftData: splitPanelContext?.selectedLeftData, }); // 좌측 패널 선택 데이터 if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) { value = splitPanelContext.selectedLeftData[mapping.sourceColumn]; console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`); } break; case "fixed": console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`); // 고정값 value = mapping.fixedValue; break; case "currentUser": console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`); // 현재 사용자 정보 switch (mapping.userField) { case "userId": value = userId; break; case "userName": value = userName; break; case "companyCode": value = companyCode; break; } console.log(`📍 currentUser 값: ${value}`); break; default: console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`); } console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`); if (value !== undefined && value !== null && value !== "") { insertData[mapping.targetColumn] = value; console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`); } else { console.log("📍 값이 비어있어서 insertData에 추가 안됨"); } } // 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만) if (splitPanelContext?.selectedLeftData) { const leftData = splitPanelContext.selectedLeftData; console.log("📍 좌측 패널 자동 매핑 시작:", leftData); // 대상 테이블의 컬럼 목록 조회 let targetTableColumns: string[] = []; try { const columnsResponse = await apiClient.get( `/table-management/tables/${quickInsertConfig.targetTable}/columns`, ); if (columnsResponse.data?.success && columnsResponse.data?.data) { const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data; targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name); console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns); } } catch (error) { console.error("대상 테이블 컬럼 조회 실패:", error); } for (const [key, val] of Object.entries(leftData)) { // 이미 매핑된 컬럼은 스킵 if (insertData[key] !== undefined) { console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`); continue; } // 대상 테이블에 해당 컬럼이 없으면 스킵 if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) { console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`); continue; } // 시스템 컬럼 제외 (id, created_date, updated_date, writer 등) const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"]; if (systemColumns.includes(key)) { console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`); continue; } // _label, _name 으로 끝나는 표시용 컬럼 제외 if (key.endsWith("_label") || key.endsWith("_name")) { console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`); continue; } // 값이 있으면 자동 추가 if (val !== undefined && val !== null && val !== "") { insertData[key] = val; console.log(`📍 자동 매핑 추가: ${key} = ${val}`); } } } console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length); // 필수 데이터 검증 if (Object.keys(insertData).length === 0) { toast.error("저장할 데이터가 없습니다."); return false; } // 중복 체크 console.log("📍 중복 체크 설정:", { enabled: quickInsertConfig.duplicateCheck?.enabled, columns: quickInsertConfig.duplicateCheck?.columns, }); if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) { const duplicateCheckData: Record = {}; for (const col of quickInsertConfig.duplicateCheck.columns) { if (insertData[col] !== undefined) { // 백엔드가 { value, operator } 형식을 기대하므로 변환 duplicateCheckData[col] = { value: insertData[col], operator: "equals" }; } } console.log("📍 중복 체크 조건:", duplicateCheckData); if (Object.keys(duplicateCheckData).length > 0) { try { const checkResponse = await apiClient.post( `/table-management/tables/${quickInsertConfig.targetTable}/data`, { page: 1, pageSize: 1, search: duplicateCheckData, }, ); console.log("📍 중복 체크 응답:", checkResponse.data); // 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] } const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || []; console.log( "📍 기존 데이터:", existingData, "길이:", Array.isArray(existingData) ? existingData.length : 0, ); if (Array.isArray(existingData) && existingData.length > 0) { toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다."); return false; } } catch (error) { console.error("중복 체크 오류:", error); // 중복 체크 실패해도 저장은 시도 } } } else { console.log("📍 중복 체크 비활성화 또는 컬럼 미설정"); } // 데이터 저장 const response = await apiClient.post( `/table-management/tables/${quickInsertConfig.targetTable}/add`, insertData, ); if (response.data?.success) { console.log("✅ Quick Insert 저장 성공"); // 저장 후 동작 설정 로그 console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert); // 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침) // refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행 const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false; console.log("📍 데이터 새로고침 여부:", shouldRefresh); if (shouldRefresh) { console.log("📍 데이터 새로고침 이벤트 발송"); // 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림 if (typeof window !== "undefined") { window.dispatchEvent(new CustomEvent("refreshTable")); window.dispatchEvent(new CustomEvent("refreshCardDisplay")); console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료"); } } // 컴포넌트 값 초기화 if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) { for (const mapping of columnMappings) { if (mapping.sourceType === "component" && mapping.sourceComponentId) { // sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용 const fieldName = mapping.sourceColumnName || mapping.sourceComponentId; context.onFormDataChange(fieldName, null); console.log(`📍 컴포넌트 값 초기화: ${fieldName}`); } } } if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) { toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다."); } return true; } else { toast.error(response.data?.message || "저장에 실패했습니다."); return false; } } catch (error: any) { console.error("❌ Quick Insert 오류:", error); toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다."); return false; } } /** * 필드 값 변경 액션 처리 (예: status를 active로 변경) * 🆕 위치정보 수집 기능 추가 * 🆕 연속 위치 추적 기능 추가 */ /** * 운행알림 및 종료 액션 처리 * - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료) */ private static async handleOperationControl( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { try { console.log("🔄 운행알림/종료 액션 실행:", { config, context }); // 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만) // updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우 const isStartMode = config.updateTrackingMode === "start" || config.updateTargetValue === "active" || config.updateTargetValue === "inactive"; if (isStartMode) { // 출발지/도착지 필드명 (기본값: departure, destination) const departureField = config.trackingDepartureField || "departure"; const destinationField = config.trackingArrivalField || "destination"; const departure = context.formData?.[departureField]; const destination = context.formData?.[destinationField]; console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination }); if (!departure || departure === "" || !destination || destination === "") { toast.error("출발지와 도착지를 먼저 선택해주세요."); return false; } } // 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료) if (this.emptyVehicleWatchId !== null) { this.stopEmptyVehicleTracking(); console.log("🛑 공차 추적 종료 후 운행 시작"); } // 🆕 연속 위치 추적 모드 처리 if (config.updateWithTracking) { const trackingConfig: ButtonActionConfig = { ...config, trackingInterval: config.updateTrackingInterval || config.trackingInterval || 10000, trackingStatusField: config.updateTargetField, trackingStatusTableName: config.updateTableName || context.tableName, trackingStatusKeyField: config.updateKeyField, trackingStatusKeySourceField: config.updateKeySourceField, }; if (config.updateTrackingMode === "start") { trackingConfig.trackingStatusOnStart = config.updateTargetValue as string; return await this.handleTrackingStart(trackingConfig, context); } else if (config.updateTrackingMode === "stop") { trackingConfig.trackingStatusOnStop = config.updateTargetValue as string; return await this.handleTrackingStop(trackingConfig, context); } } const { formData, tableName, onFormDataChange, onSave } = context; // 변경할 필드 확인 const targetField = config.updateTargetField; const targetValue = config.updateTargetValue; const multipleFields = config.updateMultipleFields || []; // 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함 if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) { toast.error("변경할 필드가 설정되지 않았습니다."); return false; } // 확인 메시지 표시 (설정된 경우) if (config.confirmMessage) { const confirmed = window.confirm(config.confirmMessage); if (!confirmed) { console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)"); return false; } } // 변경할 필드 목록 구성 const updates: Record = {}; // 단일 필드 변경 if (targetField && targetValue !== undefined) { updates[targetField] = targetValue; } // 다중 필드 변경 multipleFields.forEach(({ field, value }) => { updates[field] = value; }); // 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우) if (config.updateWithGeolocation) { const latField = config.updateGeolocationLatField; const lngField = config.updateGeolocationLngField; if (!latField || !lngField) { toast.error("위도/경도 저장 필드가 설정되지 않았습니다."); return false; } // 브라우저 Geolocation API 지원 확인 if (!navigator.geolocation) { toast.error("이 브라우저는 위치정보를 지원하지 않습니다."); return false; } // 로딩 토스트 표시 const loadingToastId = toast.loading("위치 정보를 가져오는 중..."); try { // 위치 정보 가져오기 const position = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0, }); }); toast.dismiss(loadingToastId); const { latitude, longitude, accuracy } = position.coords; const timestamp = new Date(position.timestamp); console.log("📍 위치정보 획득:", { latitude, longitude, accuracy }); // 위치정보를 updates에 추가 updates[latField] = latitude; updates[lngField] = longitude; if (config.updateGeolocationAccuracyField && accuracy !== null) { updates[config.updateGeolocationAccuracyField] = accuracy; } if (config.updateGeolocationTimestampField) { updates[config.updateGeolocationTimestampField] = timestamp.toISOString(); } } catch (geoError: any) { toast.dismiss(loadingToastId); // GeolocationPositionError 처리 if (geoError.code === 1) { toast.error("위치 정보 접근이 거부되었습니다."); } else if (geoError.code === 2) { toast.error("위치 정보를 사용할 수 없습니다."); } else if (geoError.code === 3) { toast.error("위치 정보 요청 시간이 초과되었습니다."); } else { toast.error("위치 정보를 가져오는 중 오류가 발생했습니다."); } return false; } } console.log("🔄 변경할 필드들:", updates); // formData 업데이트 if (onFormDataChange) { Object.entries(updates).forEach(([field, value]) => { onFormDataChange(field, value); }); } // 자동 저장 (기본값: true) const autoSave = config.updateAutoSave !== false; if (autoSave) { // 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE const keyField = config.updateKeyField; const keySourceField = config.updateKeySourceField; const targetTableName = config.updateTableName || tableName; if (keyField && keySourceField) { // 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID) const keyValue = resolveSpecialKeyword(keySourceField, context); console.log("🔄 필드 값 변경 - 키 필드 사용:", { targetTable: targetTableName, keyField, keySourceField, keyValue, updates, }); if (!keyValue) { console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue }); toast.error("레코드를 식별할 키 값이 없습니다."); return false; } try { // 각 필드에 대해 개별 UPDATE 호출 const { apiClient } = await import("@/lib/api/client"); for (const [field, value] of Object.entries(updates)) { console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`); const response = await apiClient.put("/dynamic-form/update-field", { tableName: targetTableName, keyField: keyField, keyValue: keyValue, updateField: field, updateValue: value, }); if (!response.data?.success) { console.error(`❌ ${field} 업데이트 실패:`, response.data); toast.error(`${field} 업데이트에 실패했습니다.`); return false; } } console.log("✅ 모든 필드 업데이트 성공"); toast.success(config.successMessage || "상태가 변경되었습니다."); // 테이블 새로고침 이벤트 발생 window.dispatchEvent( new CustomEvent("refreshTableData", { detail: { tableName: targetTableName }, }), ); return true; } catch (apiError) { console.error("❌ 필드 값 변경 API 호출 실패:", apiError); toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다."); return false; } } // onSave 콜백이 있으면 사용 if (onSave) { console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)"); try { await onSave(); toast.success(config.successMessage || "상태가 변경되었습니다."); return true; } catch (saveError) { console.error("❌ 필드 값 변경 저장 실패:", saveError); toast.error(config.errorMessage || "상태 변경 저장에 실패했습니다."); return false; } } // API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우) if (tableName && formData) { console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)"); try { // PK 필드 찾기 (id 또는 테이블명_id) const pkField = formData.id !== undefined ? "id" : `${tableName}_id`; const pkValue = formData[pkField] || formData.id; if (!pkValue) { toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요."); return false; } // 업데이트할 데이터 구성 (변경할 필드들만) const updateData = { ...updates, [pkField]: pkValue, // PK 포함 }; const response = await DynamicFormApi.updateData(tableName, updateData); if (response.success) { toast.success(config.successMessage || "상태가 변경되었습니다."); // 테이블 새로고침 이벤트 발생 window.dispatchEvent( new CustomEvent("refreshTableData", { detail: { tableName }, }), ); return true; } else { toast.error(response.message || config.errorMessage || "상태 변경에 실패했습니다."); return false; } } catch (apiError) { console.error("❌ 필드 값 변경 API 호출 실패:", apiError); toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다."); return false; } } } // 자동 저장이 비활성화된 경우 폼 데이터만 변경 toast.success(config.successMessage || "필드 값이 변경되었습니다. 저장 버튼을 눌러 저장하세요."); return true; } catch (error) { console.error("❌ 필드 값 변경 실패:", error); toast.error(config.errorMessage || "필드 값 변경 중 오류가 발생했습니다."); 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", }, openModalWithData: { type: "openModalWithData", modalSize: "md", confirmMessage: "다음 단계로 진행하시겠습니까?", successMessage: "데이터가 전달되었습니다.", errorMessage: "데이터 전달 중 오류가 발생했습니다.", }, modal: { type: "modal", modalSize: "md", }, edit: { type: "edit", successMessage: "편집되었습니다.", }, copy: { type: "copy", confirmMessage: "복사하시겠습니까?", successMessage: "복사되었습니다.", errorMessage: "복사 중 오류가 발생했습니다.", }, 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: "코드 병합 중 오류가 발생했습니다.", }, transferData: { type: "transferData", successMessage: "데이터가 전달되었습니다.", errorMessage: "데이터 전달 중 오류가 발생했습니다.", }, empty_vehicle: { type: "empty_vehicle", geolocationHighAccuracy: true, geolocationTimeout: 10000, geolocationMaxAge: 0, geolocationLatField: "latitude", geolocationLngField: "longitude", geolocationAutoSave: true, geolocationKeyField: "user_id", geolocationKeySourceField: "__userId__", geolocationExtraField: "status", geolocationExtraValue: "inactive", successMessage: "공차등록이 완료되었습니다.", errorMessage: "공차등록 중 오류가 발생했습니다.", }, operation_control: { type: "operation_control", updateAutoSave: true, updateWithGeolocation: true, updateGeolocationLatField: "latitude", updateGeolocationLngField: "longitude", updateKeyField: "user_id", updateKeySourceField: "__userId__", confirmMessage: "운행 상태를 변경하시겠습니까?", successMessage: "운행 상태가 변경되었습니다.", errorMessage: "운행 상태 변경 중 오류가 발생했습니다.", }, swap_fields: { type: "swap_fields", successMessage: "필드 값이 교환되었습니다.", errorMessage: "필드 값 교환 중 오류가 발생했습니다.", }, quickInsert: { type: "quickInsert", successMessage: "저장되었습니다.", errorMessage: "저장 중 오류가 발생했습니다.", }, };