ERP-node/frontend/lib/utils/buttonActions.ts

3657 lines
139 KiB
TypeScript
Raw Normal View History

2025-09-12 14:24:25 +09:00
"use client";
2025-10-27 11:11:08 +09:00
import React from "react";
2025-09-12 14:24:25 +09:00
import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
2025-09-29 12:17:10 +09:00
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
import type { ExtendedControlContext } from "@/types/control-management";
2025-09-12 14:24:25 +09:00
/**
*
*/
export type ButtonActionType =
| "save" // 저장
| "delete" // 삭제
| "edit" // 편집
| "copy" // 복사 (품목코드 초기화)
2025-09-12 14:24:25 +09:00
| "navigate" // 페이지 이동
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기
2025-09-12 14:24:25 +09:00
| "modal" // 모달 열기
2025-10-27 11:11:08 +09:00
| "control" // 제어 흐름
| "view_table_history" // 테이블 이력 보기
| "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge" // 코드 병합
| "geolocation" // 위치정보 가져오기
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "update_field" // 특정 필드 값 변경 (예: status를 active로)
2025-11-27 12:08:32 +09:00
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
2025-09-12 14:24:25 +09:00
/**
*
*/
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일 때 표시용 라벨
}>;
2025-10-30 12:03:50 +09:00
modalDescription?: string;
2025-09-12 14:24:25 +09:00
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
2025-09-12 14:24:25 +09:00
// 확인 메시지
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
// 제어관리 관련
enableDataflowControl?: boolean;
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍
2025-10-27 11:11:08 +09:00
// 테이블 이력 보기 관련
historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정)
historyRecordIdField?: string; // PK 필드명 (기본: "id")
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
// 엑셀 다운로드 관련
excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx)
excelSheetName?: string; // 시트명 (기본: "Sheet1")
excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true)
// 엑셀 업로드 관련
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
// 바코드 스캔 관련
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
// 코드 병합 관련
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
// 위치정보 관련
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)
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 필드 값 교환 관련 (출발지 ↔ 목적지)
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 }>; // 여러 필드 동시 변경
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
editModalDescription?: string; // 편집 모달 설명
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
2025-11-27 12:08:32 +09:00
// 데이터 전달 관련 (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; // 최대 선택 개수
};
};
2025-09-12 14:24:25 +09:00
}
/**
*
*/
export interface ButtonActionContext {
formData: Record<string, any>;
2025-09-18 18:49:30 +09:00
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
2025-09-12 14:24:25 +09:00
screenId?: number;
tableName?: string;
2025-10-29 11:26:00 +09:00
userId?: string; // 🆕 현재 로그인한 사용자 ID
userName?: string; // 🆕 현재 로그인한 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
2025-09-12 14:24:25 +09:00
onFormDataChange?: (fieldName: string, value: any) => void;
onClose?: () => void;
onRefresh?: () => void;
2025-10-23 17:55:04 +09:00
onFlowRefresh?: () => void; // 플로우 새로고침 콜백
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
2025-09-18 18:49:30 +09:00
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
2025-10-27 11:11:08 +09:00
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 제어 실행을 위한 추가 정보
buttonId?: string;
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string; // 정렬 컬럼명
sortOrder?: "asc" | "desc"; // 정렬 방향
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
2025-11-05 16:36:32 +09:00
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
searchTerm?: string; // 검색어
searchColumn?: string; // 검색 대상 컬럼
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑 (한글)
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
// 🆕 컴포넌트별 설정 (parentDataMapping 등)
componentConfigs?: Record<string, any>; // 컴포넌트 ID → 컴포넌트 설정
2025-09-12 14:24:25 +09:00
}
/**
*
*/
export class ButtonActionExecutor {
/**
*
*/
static async executeAction(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
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);
2025-09-12 14:24:25 +09:00
case "navigate":
return this.handleNavigate(config, context);
case "openModalWithData":
return await this.handleOpenModalWithData(config, context);
2025-09-12 14:24:25 +09:00
case "modal":
2025-10-30 12:03:50 +09:00
return await this.handleModal(config, context);
2025-09-12 14:24:25 +09:00
case "edit":
2025-10-30 12:03:50 +09:00
return await this.handleEdit(config, context);
2025-09-12 14:24:25 +09:00
case "control":
return this.handleControl(config, context);
2025-10-27 11:11:08 +09:00
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 "geolocation":
return await this.handleGeolocation(config, context);
case "update_field":
return await this.handleUpdateField(config, context);
2025-09-12 14:24:25 +09:00
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
}
} catch (error) {
console.error("버튼 액션 실행 오류:", error);
toast.error(config.errorMessage || "작업 중 오류가 발생했습니다.");
return false;
}
}
/**
2025-09-18 18:49:30 +09:00
* (INSERT/UPDATE - DB )
2025-09-12 14:24:25 +09:00
*/
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, originalData, tableName, screenId, onSave } = context;
2025-09-12 14:24:25 +09:00
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId, hasOnSave: !!onSave });
// 🆕 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에 포함하여 직접 수정 가능하게 함
window.dispatchEvent(new CustomEvent("beforeFormSave", {
detail: {
formData: context.formData
}
}));
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
await new Promise(resolve => setTimeout(resolve, 100));
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
// 🆕 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];
2025-11-19 10:03:38 +09:00
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 데이터 감지 실패 - 일반 저장 진행");
2025-11-19 10:03:38 +09:00
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
2025-09-12 14:24:25 +09:00
// 폼 유효성 검사
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) {
2025-09-18 18:49:30 +09:00
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
if (!primaryKeyResult.success) {
throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다.");
}
2025-09-12 14:24:25 +09:00
2025-09-18 18:49:30 +09:00
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 단순히 기본키 값 존재 여부로 판단 (임시)
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
2025-09-18 18:49:30 +09:00
let saveResult;
if (isUpdate) {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", {
primaryKeyValue,
formData,
originalData,
hasOriginalData: !!originalData,
});
if (originalData) {
// 부분 업데이트: 변경된 필드만 업데이트
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
} else {
// 전체 업데이트 (기존 방식)
console.log("📝 전체 업데이트 실행 (모든 필드)");
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
tableName,
data: formData,
});
}
} else {
// INSERT 처리
2025-10-29 11:26:00 +09:00
// 🆕 자동으로 작성자 정보 추가
if (!context.userId) {
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
}
const writerValue = context.userId;
2025-10-29 11:26:00 +09:00
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,
// });
2025-11-04 17:35:02 +09:00
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
2025-11-06 17:32:29 +09:00
// console.log("🔍 채번 규칙 할당 체크 시작");
// console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
2025-11-04 17:35:02 +09:00
const fieldsWithNumbering: Record<string, string> = {};
// formData에서 채번 규칙이 설정된 필드 찾기
for (const [key, value] of Object.entries(formData)) {
if (key.endsWith("_numberingRuleId") && value) {
const fieldName = key.replace("_numberingRuleId", "");
fieldsWithNumbering[fieldName] = value as string;
2025-11-06 17:32:29 +09:00
// console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
2025-11-04 17:35:02 +09:00
}
}
2025-11-06 17:32:29 +09:00
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
2025-11-04 17:35:02 +09:00
// 사용자 입력 값 유지 (재할당하지 않음)
// 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
// 저장 시점에는 사용자가 수정한 값을 그대로 사용
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log(" 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
console.log(" 사용자 입력 값 유지 (재할당 하지 않음)");
2025-11-04 17:35:02 +09:00
}
2025-11-06 17:32:29 +09:00
// console.log("✅ 채번 규칙 할당 완료");
// console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
2025-11-04 17:35:02 +09:00
2025-10-29 11:26:00 +09:00
const dataWithUserInfo = {
...formData,
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
created_by: writerValue, // created_by는 항상 로그인한 사람
updated_by: writerValue, // updated_by는 항상 로그인한 사람
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
2025-10-29 11:26:00 +09:00
};
2025-11-04 17:35:02 +09:00
// _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 호출
for (const itemId of deletedItemIds) {
try {
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
if (deleteResult.success) {
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
} else {
console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message);
}
} catch (deleteError) {
console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError);
}
}
}
}
}
2025-09-18 18:49:30 +09:00
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
2025-10-29 11:26:00 +09:00
data: dataWithUserInfo,
2025-09-18 18:49:30 +09:00
});
}
2025-09-12 14:24:25 +09:00
if (!saveResult.success) {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
await this.executeAfterSaveControl(config, context);
}
2025-09-12 14:24:25 +09:00
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
2025-09-12 14:24:25 +09:00
context.onRefresh?.();
2025-10-23 17:55:04 +09:00
context.onFlowRefresh?.();
2025-11-06 17:32:29 +09:00
// 저장 성공 후 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
2025-09-12 14:24:25 +09:00
return true;
} catch (error) {
console.error("저장 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
}
}
2025-09-18 18:49:30 +09:00
/**
* DB에서 formData에서
* @param formData
* @param primaryKeys DB에서
* @returns ( )
*/
private static extractPrimaryKeyValueFromDB(formData: Record<string, any>, 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<string, any>): 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;
}
2025-09-12 14:24:25 +09:00
/**
*
*/
private static async handleSubmit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 제출은 저장과 유사하지만 추가적인 처리가 있을 수 있음
return await this.handleSave(config, context);
}
/**
* 🆕 (SelectedItemsDetailInput용 - )
* ItemData[] details
*/
private static async handleBatchSave(
config: ButtonActionConfig,
context: ButtonActionContext,
selectedItemsKeys: string[]
): Promise<boolean> {
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<string, any[]> = {};
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<string, any> = {};
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<string, Array<{ id: string; [key: string]: any }>>;
}>;
// 🆕 이 컴포넌트의 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 combinations = cartesianProduct(entryArrays);
// 각 조합을 개별 레코드로 저장
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;
}
}
2025-09-12 14:24:25 +09:00
/**
*
*/
private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, tableName, screenId, selectedRowsData, flowSelectedData } = context;
2025-09-12 14:24:25 +09:00
try {
// 플로우 선택 데이터 우선 사용
let dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
console.log("🔍 handleDelete - 데이터 소스 확인:", {
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
flowSelectedDataLength: flowSelectedData?.length || 0,
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
selectedRowsDataLength: selectedRowsData?.length || 0,
dataToDeleteLength: dataToDelete?.length || 0,
});
// 다중 선택된 데이터가 있는 경우
if (dataToDelete && dataToDelete.length > 0) {
console.log(`다중 삭제 액션 실행: ${dataToDelete.length}개 항목`, dataToDelete);
2025-09-18 18:49:30 +09:00
// 테이블의 기본키 조회
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);
}
}
2025-09-18 18:49:30 +09:00
// 각 선택된 항목을 삭제
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);
}
2025-09-18 18:49:30 +09:00
console.log("선택된 행 데이터:", rowData);
console.log("최종 추출된 deleteId:", deleteId);
2025-09-18 18:49:30 +09:00
if (deleteId) {
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
if (!deleteResult.success) {
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
}
} else {
console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData);
throw new Error(
`삭제 ID를 찾을 수 없습니다. 기본키: ${primaryKeys.join(", ")}, 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`,
);
2025-09-18 18:49:30 +09:00
}
}
console.log(`✅ 다중 삭제 성공: ${dataToDelete.length}개 항목`);
2025-10-23 17:55:04 +09:00
// 데이터 소스에 따라 적절한 새로고침 호출
if (flowSelectedData && flowSelectedData.length > 0) {
console.log("🔄 플로우 데이터 삭제 완료, 플로우 새로고침 호출");
context.onFlowRefresh?.(); // 플로우 새로고침
} else {
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
context.onRefresh?.(); // 테이블 새로고침
}
2025-09-18 18:49:30 +09:00
return true;
}
// 단일 삭제 (기존 로직)
2025-09-12 14:24:25 +09:00
if (tableName && screenId && formData.id) {
2025-09-18 18:49:30 +09:00
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
2025-09-12 14:24:25 +09:00
// 실제 삭제 API 호출
2025-09-18 18:49:30 +09:00
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
2025-09-12 14:24:25 +09:00
if (!deleteResult.success) {
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
}
2025-09-18 18:49:30 +09:00
console.log("✅ 단일 삭제 성공:", deleteResult);
2025-09-12 14:24:25 +09:00
} else {
throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)");
}
context.onRefresh?.();
return true;
} catch (error) {
console.error("삭제 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
}
}
/**
*
*/
private static handleReset(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { formData, onFormDataChange } = context;
// 폼 데이터 초기화 - 각 필드를 개별적으로 초기화
if (onFormDataChange && formData) {
Object.keys(formData).forEach((key) => {
onFormDataChange(key, "");
});
}
toast.success(config.successMessage || "초기화되었습니다.");
return true;
}
/**
*
*/
private static handleCancel(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { onClose } = context;
onClose?.();
return true;
}
/**
*
*/
private static handleNavigate(config: ButtonActionConfig, context: ButtonActionContext): boolean {
let targetUrl = config.targetUrl;
// 화면 ID가 지정된 경우 URL 생성
if (config.targetScreenId) {
targetUrl = `/screens/${config.targetScreenId}`;
}
if (targetUrl) {
window.location.href = targetUrl;
return true;
}
toast.error("이동할 페이지가 지정되지 않았습니다.");
return false;
}
/**
*
*/
2025-10-30 12:03:50 +09:00
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
2025-09-12 14:24:25 +09:00
// 모달 열기 로직
console.log("모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
selectedRowsData: context.selectedRowsData,
2025-09-12 14:24:25 +09:00
});
if (config.targetScreenId) {
2025-10-30 12:03:50 +09:00
// 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);
2025-09-12 14:24:25 +09:00
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "화면",
2025-10-30 12:03:50 +09:00
description: description,
2025-09-12 14:24:25 +09:00
size: config.modalSize || "md",
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
2025-09-12 14:24:25 +09:00
},
});
2025-09-18 18:49:30 +09:00
2025-09-12 14:24:25 +09:00
window.dispatchEvent(modalEvent);
2025-10-23 13:15:52 +09:00
// 모달 열기는 조용히 처리 (토스트 불필요)
2025-09-12 14:24:25 +09:00
} else {
2025-10-23 13:15:52 +09:00
console.error("모달로 열 화면이 지정되지 않았습니다.");
2025-09-12 14:24:25 +09:00
return false;
}
return true;
}
/**
* 🆕
*/
private static async handleOpenModalWithData(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
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);
}
}
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: finalTitle, // 🆕 동적 제목 사용
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
},
});
window.dispatchEvent(modalEvent);
// 성공 메시지 (간단하게)
toast.success(config.successMessage || "다음 단계로 진행합니다.");
return true;
} else {
console.error("모달로 열 화면이 지정되지 않았습니다.");
toast.error("대상 화면이 지정되지 않았습니다.");
return false;
}
}
2025-09-12 14:24:25 +09:00
/**
*
*/
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;
}
/**
*
*/
2025-10-30 12:03:50 +09:00
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { selectedRowsData, flowSelectedData } = context;
// 플로우 선택 데이터 우선 사용
let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
2025-09-18 18:49:30 +09:00
// 선택된 데이터가 없는 경우
if (!dataToEdit || dataToEdit.length === 0) {
2025-09-18 18:49:30 +09:00
toast.error("수정할 항목을 선택해주세요.");
return false;
}
// 편집 화면이 설정되지 않은 경우
if (!config.targetScreenId) {
toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요.");
return false;
}
if (dataToEdit.length === 1) {
2025-09-18 18:49:30 +09:00
// 단일 항목 편집
const rowData = dataToEdit[0];
2025-09-18 18:49:30 +09:00
2025-10-30 12:03:50 +09:00
await this.openEditForm(config, rowData, context);
2025-09-18 18:49:30 +09:00
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
}
2025-09-12 14:24:25 +09:00
return true;
}
2025-09-18 18:49:30 +09:00
/**
* ( )
*/
2025-10-30 12:03:50 +09:00
private static async openEditForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
2025-09-18 18:49:30 +09:00
const editMode = config.editMode || "modal";
switch (editMode) {
case "modal":
// 모달로 편집 폼 열기
2025-10-30 12:03:50 +09:00
await this.openEditModal(config, rowData, context);
2025-09-18 18:49:30 +09:00
break;
case "navigate":
// 새 페이지로 이동
this.navigateToEditScreen(config, rowData, context);
break;
case "inline":
// 현재 화면에서 인라인 편집 (향후 구현)
toast.info("인라인 편집 기능은 향후 지원 예정입니다.");
break;
default:
// 기본값: 모달
await this.openEditModal(config, rowData, context);
2025-09-18 18:49:30 +09:00
}
}
/**
*
*/
2025-10-30 12:03:50 +09:00
private static async openEditModal(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
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];
}
2025-09-18 18:49:30 +09:00
2025-10-30 12:03:50 +09:00
// 1. config에 editModalDescription이 있으면 우선 사용
let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
let screenInfo: any = null;
if (config.targetScreenId) {
2025-10-30 12:03:50 +09:00
try {
screenInfo = await screenApi.getScreen(config.targetScreenId);
if (!description) {
description = screenInfo?.description || "";
}
2025-10-30 12:03:50 +09:00
} 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에서 처리)
2025-09-18 18:49:30 +09:00
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
2025-10-30 12:03:50 +09:00
title: config.editModalTitle || "데이터 수정",
description: description,
2025-09-18 18:49:30 +09:00
modalSize: config.modalSize || "lg",
editData: rowData,
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
tableName: context.tableName, // 🆕 테이블명 전달
2025-09-18 18:49:30 +09:00
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<boolean> {
try {
const { selectedRowsData, flowSelectedData } = context;
// 플로우 선택 데이터 우선 사용
let 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));
2025-11-06 17:32:29 +09:00
// 복사 시 제거할 필드들
const copiedData = { ...rowData };
2025-11-06 17:32:29 +09:00
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",
2025-11-06 17:32:29 +09:00
"item_number",
"itemNumber",
"품목코드",
"품번",
"code",
];
2025-11-06 17:32:29 +09:00
// 품목코드 필드를 찾아서 무조건 공백으로 초기화
let resetFieldName = "";
for (const field of itemCodeFields) {
if (copiedData[field] !== undefined) {
2025-11-06 17:32:29 +09:00
const originalValue = copiedData[field];
const ruleIdKey = `${field}_numberingRuleId`;
2025-11-06 17:32:29 +09:00
const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
// 품목코드를 무조건 공백으로 초기화
copiedData[field] = "";
// 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성)
if (hasNumberingRule) {
copiedData[ruleIdKey] = rowData[ruleIdKey];
2025-11-06 17:32:29 +09:00
console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`);
console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`);
2025-11-06 17:32:29 +09:00
} else {
console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`);
}
2025-11-06 17:32:29 +09:00
resetFieldName = field;
break;
}
}
2025-11-06 17:32:29 +09:00
// 작성자 정보를 현재 사용자로 변경
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) {
2025-11-06 17:32:29 +09:00
toast.success(`복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.`);
} else {
console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다.");
console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData));
2025-11-06 17:32:29 +09:00
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<void> {
try {
const editMode = config.editMode || "modal";
console.log("📋 openCopyForm 실행:", { editMode, targetScreenId: config.targetScreenId });
switch (editMode) {
case "modal":
// 모달로 복사 폼 열기 (편집 모달 재사용)
console.log("📋 모달로 복사 폼 열기");
await this.openEditModal(config, rowData, context);
break;
case "navigate":
// 새 페이지로 이동
console.log("📋 새 페이지로 복사 화면 이동");
this.navigateToCopyScreen(config, rowData, context);
break;
default:
// 기본값: 모달
console.log("📋 기본 모달로 복사 폼 열기");
this.openEditModal(config, rowData, context);
}
} 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;
}
2025-09-12 14:24:25 +09:00
/**
*
*/
private static handleClose(config: ButtonActionConfig, context: ButtonActionContext): boolean {
console.log("닫기 액션 실행:", context);
context.onClose?.();
return true;
}
/**
* ( )
*/
private static async handleControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
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) {
2025-10-24 14:11:12 +09:00
// 설정이 없으면 자동 판단 (우선순위 순서대로)
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 모드 사용");
}
}
2025-10-24 14:11:12 +09:00
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,
});
2025-09-29 12:17:10 +09:00
// 🔥 새로운 버튼 액션 실행 시스템 사용
// flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig;
if (isFlowMode && config.dataflowConfig?.flowConfig) {
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
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");
2025-10-24 14:11:12 +09:00
// 데이터 소스 준비: controlDataSource 설정 기반
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
let sourceData: any = null;
2025-10-24 14:11:12 +09:00
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;
2025-10-24 14:11:12 +09:00
}
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;
2025-10-24 14:11:12 +09:00
}
break;
case "form":
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
console.log("📝 폼 데이터 사용:", sourceData);
} else {
console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다.");
}
break;
case "both":
// 폼 + 테이블 선택
sourceData = [];
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData.push(context.formData);
}
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData.push(...context.selectedRowsData);
}
console.log("🔀 폼 + 테이블 선택 데이터 사용:", {
dataCount: sourceData.length,
sourceData,
});
break;
default:
// 자동 판단 (설정이 없는 경우)
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
sourceData = context.flowSelectedData;
dataSourceType = "flow-selection";
console.log("🌊 [자동] 플로우 선택 데이터 사용");
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
dataSourceType = "table-selection";
console.log("📊 [자동] 테이블 선택 데이터 사용");
} else if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
dataSourceType = "form";
console.log("📝 [자동] 폼 데이터 사용");
}
break;
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
}
2025-10-24 14:11:12 +09:00
console.log("📦 최종 전달 데이터:", {
dataSourceType,
sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0,
sourceData,
});
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
const result = await executeNodeFlow(flowId, {
dataSourceType,
sourceData,
context,
});
if (result.success) {
console.log("✅ 노드 플로우 실행 완료:", result);
2025-10-24 14:11:12 +09:00
toast.success("플로우 실행이 완료되었습니다.");
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
2025-10-24 14:11:12 +09:00
// 플로우 새로고침 (플로우 위젯용)
if (context.onFlowRefresh) {
console.log("🔄 플로우 새로고침 호출");
context.onFlowRefresh();
}
2025-10-27 11:11:08 +09:00
2025-10-24 14:11:12 +09:00
// 테이블 새로고침 (일반 테이블용)
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
if (context.onRefresh) {
2025-10-24 14:11:12 +09:00
console.log("🔄 테이블 새로고침 호출");
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
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) {
2025-09-29 12:17:10 +09:00
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
2025-09-29 15:21:14 +09:00
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
let mergedFormData = { ...context.formData } || {};
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
if (
controlDataSource === "table-selection" &&
context.selectedRowsData &&
context.selectedRowsData.length > 0
) {
2025-09-29 15:21:14 +09:00
// 선택된 첫 번째 행의 데이터를 formData에 병합
const selectedRowData = context.selectedRowsData[0];
mergedFormData = { ...mergedFormData, ...selectedRowData };
console.log("🔄 선택된 행 데이터를 formData에 병합:", {
originalFormData: context.formData,
selectedRowData,
mergedFormData,
});
}
2025-09-29 12:17:10 +09:00
// 새로운 ImprovedButtonActionExecutor 사용
const buttonConfig = {
actionType: config.type,
dataflowConfig: config.dataflowConfig,
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
};
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
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,
});
2025-09-29 12:17:10 +09:00
if (executionResult.success) {
console.log("✅ 관계 실행 완료:", executionResult);
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
2025-09-29 12:17:10 +09:00
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
2025-09-29 12:17:10 +09:00
return true;
} else {
2025-09-29 12:17:10 +09:00
console.error("❌ 관계 실행 실패:", executionResult);
toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다.");
return false;
}
2025-09-29 12:17:10 +09:00
} else {
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
// 제어 없음 - 성공 처리
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
return true;
}
} catch (error) {
console.error("제어 조건 검증 중 오류:", error);
toast.error("제어 조건 검증 중 오류가 발생했습니다.");
return false;
}
}
/**
* (After Timing)
*/
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
private static async executeAfterSaveControl(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<void> {
console.log("🎯 저장 후 제어 실행:", {
enableDataflowControl: config.enableDataflowControl,
dataflowConfig: config.dataflowConfig,
dataflowTiming: config.dataflowTiming,
});
// dataflowTiming이 'after'가 아니면 실행하지 않음
if (config.dataflowTiming && config.dataflowTiming !== "after") {
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
return;
}
// 제어 데이터 소스 결정
let controlDataSource = config.dataflowConfig?.controlDataSource;
if (!controlDataSource) {
controlDataSource = "form"; // 저장 후에는 기본적으로 form 데이터 사용
}
const extendedContext: ExtendedControlContext = {
formData: context.formData || {},
selectedRows: context.selectedRows || [],
selectedRowsData: context.selectedRowsData || [],
controlDataSource,
};
// 관계 기반 제어 실행
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
const buttonConfig = {
actionType: config.type,
dataflowConfig: config.dataflowConfig,
enableDataflowControl: true,
};
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
buttonConfig,
context.formData || {},
{
buttonId: context.buttonId || "unknown",
screenId: context.screenId || "unknown",
userId: context.userId || "unknown",
companyCode: context.companyCode || "*",
startTime: Date.now(),
contextData: context,
feat: 노드 기반 데이터 플로우 시스템 구현 - 노드 에디터 UI 구현 (React Flow 기반) - TableSource, DataTransform, INSERT, UPDATE, DELETE, UPSERT 노드 - 드래그앤드롭 노드 추가 및 연결 - 속성 패널을 통한 노드 설정 - 실시간 필드 라벨 표시 (column_labels 테이블 연동) - 데이터 변환 노드 (DataTransform) 기능 - EXPLODE: 구분자로 1개 행 → 여러 행 확장 - UPPERCASE, LOWERCASE, TRIM, CONCAT, SPLIT, REPLACE 등 12가지 변환 타입 - In-place 변환 지원 (타겟 필드 생략 시 소스 필드 덮어쓰기) - 변환된 필드가 하위 액션 노드에 자동 전달 - 노드 플로우 실행 엔진 - 위상 정렬을 통한 노드 실행 순서 결정 - 레벨별 병렬 실행 (Promise.allSettled) - 부분 실패 허용 (한 노드 실패 시 연결된 하위 노드만 스킵) - 트랜잭션 기반 안전한 데이터 처리 - UPSERT 액션 로직 구현 - DB 제약 조건 없이 SELECT → UPDATE or INSERT 방식 - 복합 충돌 키 지원 (예: sales_no + product_name) - 파라미터 인덱스 정확한 매핑 - 데이터 소스 자동 감지 - 테이블 선택 데이터 (selectedRowsData) 자동 주입 - 폼 입력 데이터 (formData) 자동 주입 - TableSource 노드가 외부 데이터 우선 사용 - 버튼 컴포넌트 통합 - 기존 관계 실행 + 새 노드 플로우 실행 하이브리드 지원 - 노드 플로우 선택 UI 추가 - API 클라이언트 통합 (Axios) - 개발 문서 작성 - 노드 기반 제어 시스템 개선 계획 - 노드 연결 규칙 설계 - 노드 실행 엔진 설계 - 노드 구조 개선안 - 버튼 통합 분석
2025-10-02 16:22:29 +09:00
},
);
if (executionResult.success) {
console.log("✅ 저장 후 제어 실행 완료:", executionResult);
// 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음
} else {
console.error("❌ 저장 후 제어 실행 실패:", executionResult);
toast.error("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
}
}
/**
*
*/
private static async executeRelationshipActions(actions: any[], context: ButtonActionContext): Promise<void> {
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<void> {
console.log("💾 저장 액션 실행:", action);
console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2));
// 🎯 필드 매핑 정보 사용하여 저장 데이터 구성
let saveData: Record<string, any> = {};
// 액션에 필드 매핑 정보가 있는지 확인
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<void> {
console.log("🔄 업데이트 액션 실행:", action);
console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2));
// 🎯 필드 매핑 정보 사용하여 업데이트 데이터 구성
let updateData: Record<string, any> = {};
// 액션에 필드 매핑 정보가 있는지 확인
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<void> {
console.log("🗑️ 삭제 액션 실행:", action);
// 실제 삭제 로직 (기존 handleDelete와 유사)
if (!context.selectedRowsData || context.selectedRowsData.length === 0) {
throw new Error("삭제할 항목을 선택해주세요.");
}
const deleteData = context.selectedRowsData[0];
console.log("삭제할 데이터:", deleteData);
try {
// 🔥 실제 삭제 API 호출
if (!context.tableName) {
throw new Error("테이블명이 설정되지 않았습니다.");
}
// 기존 handleDelete와 동일한 로직으로 ID 찾기
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(context.tableName);
let deleteId: string | undefined;
if (primaryKeysResult.success && primaryKeysResult.data && primaryKeysResult.data.length > 0) {
deleteId = deleteData[primaryKeysResult.data[0]];
}
if (!deleteId) {
// 폴백: 일반적인 ID 필드들 확인
const commonIdFields = ["id", "objid", "pk", "sales_no", "contract_no"];
for (const field of commonIdFields) {
if (deleteData[field]) {
deleteId = deleteData[field];
break;
}
}
}
if (!deleteId) {
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
}
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName);
if (result.success) {
console.log("✅ 삭제 성공:", result);
toast.success("데이터가 삭제되었습니다.");
} else {
throw new Error(result.message || "삭제 실패");
}
} catch (error) {
console.error("❌ 삭제 실패:", error);
toast.error(`삭제 실패: ${error.message}`);
throw error;
}
}
/**
* ( )
*/
private static async executeActionInsert(action: any, context: ButtonActionContext): Promise<void> {
console.log(" 삽입 액션 실행:", action);
let insertData: Record<string, any> = {};
// 액션에 필드 매핑 정보가 있는지 확인
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;
}
}
2025-10-27 11:11:08 +09:00
/**
*
*/
private static async handleViewTableHistory(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
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<boolean> {
try {
// 동적 import로 엑셀 유틸리티 로드
const { exportToExcel } = await import("@/lib/utils/excelExport");
let dataToExport: any[] = [];
// ✅ 항상 API 호출로 필터링된 전체 데이터 가져오기
if (context.tableName) {
2025-11-05 10:23:00 +09:00
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;
2025-11-05 10:23:00 +09:00
}
}
// 폴백: 폼 데이터
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;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
let visibleColumns: string[] | undefined = undefined;
let columnLabels: Record<string, string> | undefined = undefined;
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) {
let 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<string, Record<string, string>> = {};
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<string, any> = {};
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;
2025-11-05 10:23:00 +09:00
});
}
// 최대 행 수 제한
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<boolean> {
try {
console.log("📤 엑셀 업로드 모달 열기:", {
config,
context,
userId: context.userId,
tableName: context.tableName,
});
// 동적 import로 모달 컴포넌트 로드
const { ExcelUploadModal } = await import("@/components/common/ExcelUploadModal");
const { createRoot } = await import("react-dom/client");
// 모달 컨테이너 생성
const modalContainer = document.createElement("div");
document.body.appendChild(modalContainer);
const root = createRoot(modalContainer);
const closeModal = () => {
root.unmount();
document.body.removeChild(modalContainer);
};
// localStorage 디버깅
const modalId = `excel-upload-${context.tableName || ""}`;
const storageKey = `modal_size_${modalId}_${context.userId || "guest"}`;
console.log("🔍 엑셀 업로드 모달 localStorage 확인:", {
modalId,
userId: context.userId,
storageKey,
savedSize: localStorage.getItem(storageKey),
});
root.render(
React.createElement(ExcelUploadModal, {
open: true,
onOpenChange: (open: boolean) => {
if (!open) {
// 모달 닫을 때 localStorage 확인
console.log("🔍 모달 닫을 때 localStorage:", {
storageKey,
savedSize: localStorage.getItem(storageKey),
});
closeModal();
}
},
tableName: context.tableName || "",
uploadMode: config.excelUploadMode || "insert",
keyColumn: config.excelKeyColumn,
2025-11-05 16:36:32 +09:00
userId: context.userId,
onSuccess: () => {
// 성공 메시지는 ExcelUploadModal 내부에서 이미 표시됨
context.onRefresh?.();
closeModal();
},
}),
);
return true;
} catch (error) {
console.error("❌ 엑셀 업로드 모달 열기 실패:", error);
toast.error(config.errorMessage || "엑셀 업로드 중 오류가 발생했습니다.");
return false;
}
}
/**
*
*/
private static async handleBarcodeScan(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
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,
2025-11-05 16:36:32 +09:00
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<boolean> {
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 = `
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: white; padding: 24px; border-radius: 8px; max-width: 500px; width: 90%;">
<h3 style="margin: 0 0 16px 0; font-size: 18px; font-weight: 600;"> </h3>
<p style="margin: 0 0 24px 0; color: #666;"> ?</p>
<div style="display: flex; gap: 12px; margin-bottom: 24px;">
<button id="merge-option-1" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
<div style="font-weight: 600; margin-bottom: 4px;">${value1}</div>
<div style="font-size: 12px; color: #666;"> ${value2} </div>
</button>
<button id="merge-option-2" style="flex: 1; padding: 16px; border: 2px solid #e5e7eb; border-radius: 8px; background: white; cursor: pointer; transition: all 0.2s;">
<div style="font-weight: 600; margin-bottom: 4px;">${value2}</div>
<div style="font-size: 12px; color: #666;"> ${value1} </div>
</button>
</div>
<div style="display: flex; gap: 8px; justify-content: flex-end;">
<button id="merge-cancel" style="padding: 8px 16px; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer;"></button>
</div>
</div>
</div>
`;
const modalContainer = document.createElement("div");
modalContainer.innerHTML = modalHtml;
document.body.appendChild(modalContainer);
const option1Btn = modalContainer.querySelector("#merge-option-1") as HTMLButtonElement;
const option2Btn = modalContainer.querySelector("#merge-option-2") as HTMLButtonElement;
const cancelBtn = modalContainer.querySelector("#merge-cancel") as HTMLButtonElement;
// 호버 효과
[option1Btn, option2Btn].forEach((btn) => {
btn.addEventListener("mouseenter", () => {
btn.style.borderColor = "#3b82f6";
btn.style.background = "#eff6ff";
});
btn.addEventListener("mouseleave", () => {
btn.style.borderColor = "#e5e7eb";
btn.style.background = "white";
});
});
option1Btn.addEventListener("click", () => {
document.body.removeChild(modalContainer);
resolve({ confirmed: true, oldValue: value2, newValue: value1 });
});
option2Btn.addEventListener("click", () => {
document.body.removeChild(modalContainer);
resolve({ confirmed: true, oldValue: value1, newValue: value2 });
});
cancelBtn.addEventListener("click", () => {
document.body.removeChild(modalContainer);
resolve({ confirmed: false, oldValue: "", newValue: "" });
});
});
if (!confirmed.confirmed) {
return false;
}
const { oldValue, newValue } = confirmed;
// 미리보기 표시 (옵션)
if (config.mergeShowPreview !== false) {
const { apiClient } = await import("@/lib/api/client");
const previewResponse = await apiClient.post("/code-merge/preview", {
columnName,
oldValue,
});
if (previewResponse.data.success) {
const preview = previewResponse.data.data;
const totalRows = preview.totalAffectedRows;
const confirmMerge = confirm(
`⚠️ 코드 병합 확인\n\n` +
`${oldValue}${newValue}\n\n` +
`영향받는 데이터:\n` +
`- 테이블 수: ${preview.preview.length}\n` +
`- 총 행 수: ${totalRows}\n\n` +
`데이터는 삭제되지 않고, "${columnName}" 컬럼 값만 변경됩니다.\n\n` +
`계속하시겠습니까?`
);
if (!confirmMerge) {
return false;
}
}
}
// 병합 실행
toast.loading("코드 병합 중...", { duration: Infinity });
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.post("/code-merge/merge-all-tables", {
columnName,
oldValue,
newValue,
});
toast.dismiss();
if (response.data.success) {
const data = response.data.data;
toast.success(
`코드 병합 완료!\n` +
`${data.affectedTables.length}개 테이블, ${data.totalRowsUpdated}개 행 업데이트`
);
// 화면 새로고침
context.onRefresh?.();
context.onFlowRefresh?.();
return true;
} else {
toast.error(response.data.message || "코드 병합에 실패했습니다.");
return false;
}
} catch (error: any) {
console.error("❌ 코드 병합 실패:", error);
toast.dismiss();
toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다.");
return false;
}
}
/**
*
*/
private static async handleGeolocation(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📍 위치정보 가져오기 액션 실행:", { config, context });
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 위도/경도 저장 필드 확인
const latField = config.geolocationLatField;
const lngField = config.geolocationLngField;
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
// Geolocation 옵션 설정
const options: PositionOptions = {
enableHighAccuracy: config.geolocationHighAccuracy !== false, // 기본 true
timeout: config.geolocationTimeout || 10000, // 기본 10초
maximumAge: config.geolocationMaxAge || 0, // 기본 0 (항상 새로운 위치)
};
// 위치 정보 가져오기 (Promise로 래핑)
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
// 로딩 토스트 제거
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy, altitude, heading, speed } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", {
latitude,
longitude,
accuracy,
timestamp: timestamp.toISOString(),
});
// 폼 데이터 업데이트
const updates: Record<string, any> = {
[latField]: latitude,
[lngField]: longitude,
};
// 선택적 필드들
if (config.geolocationAccuracyField && accuracy !== null) {
updates[config.geolocationAccuracyField] = accuracy;
}
if (config.geolocationTimestampField) {
updates[config.geolocationTimestampField] = timestamp.toISOString();
}
// 🆕 추가 필드 변경 (위치정보 + 상태변경)
let extraTableUpdated = false;
if (config.geolocationUpdateField && config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
const extraTableName = config.geolocationExtraTableName;
const currentTableName = config.geolocationTableName || context.tableName;
// 다른 테이블에 UPDATE하는 경우
if (extraTableName && extraTableName !== currentTableName) {
console.log("📍 다른 테이블 필드 변경:", {
targetTable: extraTableName,
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
keyField: config.geolocationExtraKeyField,
keySourceField: config.geolocationExtraKeySourceField,
});
// 키 값 가져오기
const keyValue = context.formData?.[config.geolocationExtraKeySourceField || ""];
if (keyValue && config.geolocationExtraKeyField) {
try {
// 다른 테이블 UPDATE API 호출
const { apiClient } = await import("@/lib/api/client");
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: extraTableName,
keyField: config.geolocationExtraKeyField,
keyValue: keyValue,
updateField: config.geolocationExtraField,
updateValue: config.geolocationExtraValue,
});
if (response.data?.success) {
extraTableUpdated = true;
console.log("✅ 다른 테이블 UPDATE 성공:", response.data);
} else {
console.error("❌ 다른 테이블 UPDATE 실패:", response.data);
toast.error(`${extraTableName} 테이블 업데이트에 실패했습니다.`);
}
} catch (apiError) {
console.error("❌ 다른 테이블 UPDATE API 오류:", apiError);
toast.error(`${extraTableName} 테이블 업데이트 중 오류가 발생했습니다.`);
}
} else {
console.warn("⚠️ 키 값이 없어서 다른 테이블 UPDATE를 건너뜁니다:", {
keySourceField: config.geolocationExtraKeySourceField,
keyValue,
});
}
} else {
// 같은 테이블 (현재 폼 데이터에 추가)
updates[config.geolocationExtraField] = config.geolocationExtraValue;
console.log("📍 같은 테이블 추가 필드 변경:", {
field: config.geolocationExtraField,
value: config.geolocationExtraValue,
});
}
}
// formData 업데이트
if (context.onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
context.onFormDataChange?.(field, value);
});
}
// 성공 메시지 생성
let successMsg = config.successMessage ||
`위치 정보를 가져왔습니다.\n위도: ${latitude.toFixed(6)}, 경도: ${longitude.toFixed(6)}`;
// 추가 필드 변경이 있으면 메시지에 포함
if (config.geolocationUpdateField && config.geolocationExtraField) {
if (extraTableUpdated) {
successMsg += `\n[${config.geolocationExtraTableName}] ${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
} else if (!config.geolocationExtraTableName || config.geolocationExtraTableName === (config.geolocationTableName || context.tableName)) {
successMsg += `\n${config.geolocationExtraField}: ${config.geolocationExtraValue}`;
}
}
// 성공 메시지 표시
toast.success(successMsg);
// 자동 저장 옵션이 활성화된 경우
if (config.geolocationAutoSave && context.onSave) {
console.log("📍 위치정보 자동 저장 실행");
try {
await context.onSave();
toast.success("위치 정보가 저장되었습니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
}
}
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;
}
}
/**
* (: status를 active로 )
*/
private static async handleUpdateField(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 변경 액션 실행:", { config, context });
const { formData, tableName, onFormDataChange, onSave } = context;
// 변경할 필드 확인
const targetField = config.updateTargetField;
const targetValue = config.updateTargetValue;
const multipleFields = config.updateMultipleFields || [];
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
if (!targetField && multipleFields.length === 0) {
toast.error("변경할 필드가 설정되지 않았습니다.");
return false;
}
// 확인 메시지 표시 (설정된 경우)
if (config.confirmMessage) {
const confirmed = window.confirm(config.confirmMessage);
if (!confirmed) {
console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)");
return false;
}
}
// 변경할 필드 목록 구성
const updates: Record<string, any> = {};
// 단일 필드 변경
if (targetField && targetValue !== undefined) {
updates[targetField] = targetValue;
}
// 다중 필드 변경
multipleFields.forEach(({ field, value }) => {
updates[field] = value;
});
console.log("🔄 변경할 필드들:", updates);
// formData 업데이트
if (onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
onFormDataChange(field, value);
});
}
// 자동 저장 (기본값: true)
const autoSave = config.updateAutoSave !== false;
if (autoSave) {
// 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를 통한 직접 저장
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;
}
}
2025-09-12 14:24:25 +09:00
/**
*
*/
private static validateFormData(formData: Record<string, any>): {
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<ButtonActionType, Partial<ButtonActionConfig>> = {
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: "데이터 전달 중 오류가 발생했습니다.",
},
2025-09-12 14:24:25 +09:00
modal: {
type: "modal",
modalSize: "md",
},
edit: {
type: "edit",
successMessage: "편집되었습니다.",
},
2025-10-23 13:15:52 +09:00
control: {
type: "control",
2025-09-12 14:24:25 +09:00
},
2025-10-27 11:11:08 +09:00
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: "코드 병합 중 오류가 발생했습니다.",
},
geolocation: {
type: "geolocation",
geolocationHighAccuracy: true,
geolocationTimeout: 10000,
geolocationMaxAge: 0,
geolocationAutoSave: false,
confirmMessage: "현재 위치 정보를 가져오시겠습니까?",
successMessage: "위치 정보를 가져왔습니다.",
errorMessage: "위치 정보를 가져오는 중 오류가 발생했습니다.",
},
update_field: {
type: "update_field",
updateAutoSave: true,
confirmMessage: "상태를 변경하시겠습니까?",
successMessage: "상태가 변경되었습니다.",
errorMessage: "상태 변경 중 오류가 발생했습니다.",
},
2025-09-12 14:24:25 +09:00
};