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

2733 lines
101 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import React from "react";
import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
import type { ExtendedControlContext } from "@/types/control-management";
/**
* 버튼 액션 타입 정의
*/
export type ButtonActionType =
| "save" // 저장
| "delete" // 삭제
| "edit" // 편집
| "copy" // 복사 (품목코드 초기화)
| "navigate" // 페이지 이동
| "openModalWithData" // 🆕 데이터를 전달하면서 모달 열기
| "modal" // 모달 열기
| "control" // 제어 흐름
| "view_table_history" // 테이블 이력 보기
| "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge"; // 코드 병합
/**
* 버튼 액션 설정
*/
export interface ButtonActionConfig {
type: ButtonActionType;
// 저장/제출 관련
saveEndpoint?: string;
validateForm?: boolean;
// 네비게이션 관련
targetUrl?: string;
targetScreenId?: number;
// 모달/팝업 관련
modalTitle?: string;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
// 확인 메시지
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
// 제어관리 관련
enableDataflowControl?: boolean;
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍
// 테이블 이력 보기 관련
historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정)
historyRecordIdField?: string; // PK 필드명 (기본: "id")
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
// 엑셀 다운로드 관련
excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx)
excelSheetName?: string; // 시트명 (기본: "Sheet1")
excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true)
// 엑셀 업로드 관련
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
// 바코드 스캔 관련
barcodeTargetField?: string; // 스캔 결과를 입력할 필드명
barcodeFormat?: "all" | "1d" | "2d"; // 바코드 포맷 (기본: "all")
barcodeAutoSubmit?: boolean; // 스캔 후 자동 제출 여부
// 코드 병합 관련
mergeColumnName?: string; // 병합할 컬럼명 (예: "item_code")
mergeShowPreview?: boolean; // 병합 전 미리보기 표시 여부 (기본: true)
}
/**
* 버튼 액션 실행 컨텍스트
*/
export interface ButtonActionContext {
formData: Record<string, any>;
originalData?: Record<string, any>; // 부분 업데이트용 원본 데이터
screenId?: number;
tableName?: string;
userId?: string; // 🆕 현재 로그인한 사용자 ID
userName?: string; // 🆕 현재 로그인한 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
onFormDataChange?: (fieldName: string, value: any) => void;
onClose?: () => void;
onRefresh?: () => void;
onFlowRefresh?: () => void; // 플로우 새로고침 콜백
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 제어 실행을 위한 추가 정보
buttonId?: string;
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string; // 정렬 컬럼명
sortOrder?: "asc" | "desc"; // 정렬 방향
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
filterConditions?: Record<string, any>; // 필터 조건 (예: { status: "active", dept: "dev" })
searchTerm?: string; // 검색어
searchColumn?: string; // 검색 대상 컬럼
visibleColumns?: string[]; // 화면에 표시 중인 컬럼 목록 (순서 포함)
columnLabels?: Record<string, string>; // 컬럼명 → 라벨명 매핑 (한글)
currentPage?: number; // 현재 페이지
pageSize?: number; // 페이지 크기
totalItems?: number; // 전체 항목 수
}
/**
* 버튼 액션 실행기
*/
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);
case "navigate":
return this.handleNavigate(config, context);
case "openModalWithData":
return await this.handleOpenModalWithData(config, context);
case "modal":
return await this.handleModal(config, context);
case "edit":
return await this.handleEdit(config, context);
case "control":
return this.handleControl(config, context);
case "view_table_history":
return this.handleViewTableHistory(config, context);
case "excel_download":
return await this.handleExcelDownload(config, context);
case "excel_upload":
return await this.handleExcelUpload(config, context);
case "barcode_scan":
return await this.handleBarcodeScan(config, context);
case "code_merge":
return await this.handleCodeMerge(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
}
} catch (error) {
console.error("버튼 액션 실행 오류:", error);
toast.error(config.errorMessage || "작업 중 오류가 발생했습니다.");
return false;
}
}
/**
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
*/
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, originalData, tableName, screenId } = context;
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
if (!validation.isValid) {
toast.error(`입력값을 확인해주세요: ${validation.errors.join(", ")}`);
return false;
}
}
try {
// API 엔드포인트가 지정된 경우
if (config.saveEndpoint) {
const response = await fetch(config.saveEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error(`저장 실패: ${response.statusText}`);
}
} else if (tableName && screenId) {
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
if (!primaryKeyResult.success) {
throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다.");
}
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 단순히 기본키 값 존재 여부로 판단 (임시)
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
let saveResult;
if (isUpdate) {
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
console.log("🔄 UPDATE 모드로 저장:", {
primaryKeyValue,
formData,
originalData,
hasOriginalData: !!originalData,
});
if (originalData) {
// 부분 업데이트: 변경된 필드만 업데이트
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
} else {
// 전체 업데이트 (기존 방식)
console.log("📝 전체 업데이트 실행 (모든 필드)");
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
tableName,
data: formData,
});
}
} else {
// INSERT 처리
// 🆕 자동으로 작성자 정보 추가
if (!context.userId) {
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
}
const writerValue = context.userId;
const companyCodeValue = context.companyCode || "";
// console.log("👤 [buttonActions] 사용자 정보:", {
// userId: context.userId,
// userName: context.userName,
// companyCode: context.companyCode, // ✅ 회사 코드
// formDataWriter: formData.writer, // ✅ 폼에서 입력한 writer 값
// formDataCompanyCode: formData.company_code, // ✅ 폼에서 입력한 company_code 값
// defaultWriterValue: writerValue,
// companyCodeValue, // ✅ 최종 회사 코드 값
// });
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
// console.log("🔍 채번 규칙 할당 체크 시작");
// console.log("📦 현재 formData:", JSON.stringify(formData, null, 2));
const fieldsWithNumbering: Record<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;
// console.log(`🎯 발견: ${fieldName} → 규칙 ${value}`);
}
}
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
// 각 필드에 대해 실제 코드 할당
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
// console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
const response = await allocateNumberingCode(ruleId);
// console.log(`📡 API 응답 (${fieldName}):`, response);
if (response.success && response.data) {
const generatedCode = response.data.generatedCode;
formData[fieldName] = generatedCode;
// console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`);
} else {
console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
}
} catch (error) {
console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
toast.error(`${fieldName} 채번 규칙 할당 오류`);
}
}
// console.log("✅ 채번 규칙 할당 완료");
// console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
const dataWithUserInfo = {
...formData,
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
created_by: writerValue, // created_by는 항상 로그인한 사람
updated_by: writerValue, // updated_by는 항상 로그인한 사람
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
};
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataWithUserInfo)) {
if (key.endsWith("_numberingRuleId")) {
delete dataWithUserInfo[key];
}
}
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: dataWithUserInfo,
});
}
if (!saveResult.success) {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
await this.executeAfterSaveControl(config, context);
}
} else {
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
}
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
context.onRefresh?.();
context.onFlowRefresh?.();
// 저장 성공 후 이벤트 발생
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
return true;
} catch (error) {
console.error("저장 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
}
}
/**
* DB에서 조회한 실제 기본키로 formData에서 값 추출
* @param formData 폼 데이터
* @param primaryKeys DB에서 조회한 실제 기본키 컬럼명 배열
* @returns 기본키 값 (복합키의 경우 첫 번째 키 값)
*/
private static extractPrimaryKeyValueFromDB(formData: Record<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;
}
/**
* 제출 액션 처리
*/
private static async handleSubmit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 제출은 저장과 유사하지만 추가적인 처리가 있을 수 있음
return await this.handleSave(config, context);
}
/**
* 삭제 액션 처리
*/
private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { formData, tableName, screenId, selectedRowsData, flowSelectedData } = context;
try {
// 플로우 선택 데이터 우선 사용
let dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
console.log("🔍 handleDelete - 데이터 소스 확인:", {
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
flowSelectedDataLength: flowSelectedData?.length || 0,
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
selectedRowsDataLength: selectedRowsData?.length || 0,
dataToDeleteLength: dataToDelete?.length || 0,
});
// 다중 선택된 데이터가 있는 경우
if (dataToDelete && dataToDelete.length > 0) {
console.log(`다중 삭제 액션 실행: ${dataToDelete.length}개 항목`, dataToDelete);
// 테이블의 기본키 조회
let primaryKeys: string[] = [];
if (tableName) {
try {
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
if (primaryKeysResult.success && primaryKeysResult.data) {
primaryKeys = primaryKeysResult.data;
console.log(`🔑 테이블 ${tableName}의 기본키:`, primaryKeys);
}
} catch (error) {
console.warn("기본키 조회 실패, 폴백 방법 사용:", error);
}
}
// 각 선택된 항목을 삭제
for (const rowData of dataToDelete) {
let deleteId: any = null;
// 1순위: 데이터베이스에서 조회한 기본키 사용
if (primaryKeys.length > 0) {
const primaryKey = primaryKeys[0]; // 첫 번째 기본키 사용
deleteId = rowData[primaryKey];
console.log(`📊 기본키 ${primaryKey}로 ID 추출:`, deleteId);
}
// 2순위: 폴백 - 일반적인 ID 필드명들 시도
if (!deleteId) {
deleteId =
rowData.id ||
rowData.objid ||
rowData.pk ||
rowData.ID ||
rowData.OBJID ||
rowData.PK ||
// 테이블별 기본키 패턴들
rowData.sales_no ||
rowData.contract_no ||
rowData.order_no ||
rowData.seq_no ||
rowData.code ||
rowData.code_id ||
rowData.user_id ||
rowData.menu_id;
// _no로 끝나는 필드들 찾기
if (!deleteId) {
const noField = Object.keys(rowData).find((key) => key.endsWith("_no") && rowData[key]);
if (noField) deleteId = rowData[noField];
}
// _id로 끝나는 필드들 찾기
if (!deleteId) {
const idField = Object.keys(rowData).find((key) => key.endsWith("_id") && rowData[key]);
if (idField) deleteId = rowData[idField];
}
console.log(`🔍 폴백 방법으로 ID 추출:`, deleteId);
}
console.log("선택된 행 데이터:", rowData);
console.log("최종 추출된 deleteId:", deleteId);
if (deleteId) {
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deleteId, tableName);
if (!deleteResult.success) {
throw new Error(`ID ${deleteId} 삭제 실패: ${deleteResult.message}`);
}
} else {
console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData);
throw new Error(
`삭제 ID를 찾을 수 없습니다. 기본키: ${primaryKeys.join(", ")}, 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`,
);
}
}
console.log(`✅ 다중 삭제 성공: ${dataToDelete.length}개 항목`);
// 데이터 소스에 따라 적절한 새로고침 호출
if (flowSelectedData && flowSelectedData.length > 0) {
console.log("🔄 플로우 데이터 삭제 완료, 플로우 새로고침 호출");
context.onFlowRefresh?.(); // 플로우 새로고침
} else {
console.log("🔄 테이블 데이터 삭제 완료, 테이블 새로고침 호출");
context.onRefresh?.(); // 테이블 새로고침
}
return true;
}
// 단일 삭제 (기존 로직)
if (tableName && screenId && formData.id) {
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
// 실제 삭제 API 호출
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(formData.id, tableName);
if (!deleteResult.success) {
throw new Error(deleteResult.message || "삭제에 실패했습니다.");
}
console.log("✅ 단일 삭제 성공:", deleteResult);
} else {
throw new Error("삭제에 필요한 정보가 부족합니다. (ID, 테이블명 또는 화면ID 누락)");
}
context.onRefresh?.();
return true;
} catch (error) {
console.error("삭제 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
}
}
/**
* 초기화 액션 처리
*/
private static handleReset(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { formData, onFormDataChange } = context;
// 폼 데이터 초기화 - 각 필드를 개별적으로 초기화
if (onFormDataChange && formData) {
Object.keys(formData).forEach((key) => {
onFormDataChange(key, "");
});
}
toast.success(config.successMessage || "초기화되었습니다.");
return true;
}
/**
* 취소 액션 처리
*/
private static handleCancel(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { onClose } = context;
onClose?.();
return true;
}
/**
* 네비게이션 액션 처리
*/
private static handleNavigate(config: ButtonActionConfig, context: ButtonActionContext): boolean {
let targetUrl = config.targetUrl;
// 화면 ID가 지정된 경우 URL 생성
if (config.targetScreenId) {
targetUrl = `/screens/${config.targetScreenId}`;
}
if (targetUrl) {
window.location.href = targetUrl;
return true;
}
toast.error("이동할 페이지가 지정되지 않았습니다.");
return false;
}
/**
* 모달 액션 처리
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
});
if (config.targetScreenId) {
// 1. config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
},
});
window.dispatchEvent(modalEvent);
// 모달 열기는 조용히 처리 (토스트 불필요)
} else {
console.error("모달로 열 화면이 지정되지 않았습니다.");
return false;
}
return true;
}
/**
* 🆕 데이터를 전달하면서 모달 열기 액션 처리
*/
private static async handleOpenModalWithData(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<boolean> {
console.log("📦 데이터와 함께 모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
dataSourceId: config.dataSourceId,
});
// 1. dataSourceId 확인 (없으면 selectedRows에서 데이터 전달)
const dataSourceId = config.dataSourceId || context.tableName || "default";
// 2. modalDataStore에서 데이터 확인
try {
const { useModalDataStore } = await import("@/stores/modalDataStore");
const modalData = useModalDataStore.getState().dataRegistry[dataSourceId] || [];
if (modalData.length === 0) {
console.warn("⚠️ 전달할 데이터가 없습니다:", dataSourceId);
toast.warning("선택된 데이터가 없습니다. 먼저 항목을 선택해주세요.");
return false;
}
console.log("✅ 전달할 데이터:", {
dataSourceId,
count: modalData.length,
data: modalData,
});
} catch (error) {
console.error("❌ 데이터 확인 실패:", error);
toast.error("데이터 확인 중 오류가 발생했습니다.");
return false;
}
// 3. 모달 열기
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);
}
}
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "데이터 입력",
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
},
});
window.dispatchEvent(modalEvent);
// 성공 메시지 (간단하게)
toast.success(config.successMessage || "다음 단계로 진행합니다.");
return true;
} else {
console.error("모달로 열 화면이 지정되지 않았습니다.");
toast.error("대상 화면이 지정되지 않았습니다.");
return false;
}
}
/**
* 새 창 액션 처리
*/
private static handleNewWindow(config: ButtonActionConfig, context: ButtonActionContext): boolean {
let targetUrl = config.targetUrl;
// 화면 ID가 지정된 경우 URL 생성
if (config.targetScreenId) {
targetUrl = `/screens/${config.targetScreenId}`;
}
if (targetUrl) {
const windowFeatures = `width=${config.popupWidth || 800},height=${config.popupHeight || 600},scrollbars=yes,resizable=yes`;
window.open(targetUrl, "_blank", windowFeatures);
return true;
}
toast.error("열 페이지가 지정되지 않았습니다.");
return false;
}
/**
* 팝업 액션 처리
*/
private static handlePopup(config: ButtonActionConfig, context: ButtonActionContext): boolean {
// 팝업은 새 창과 유사하지만 더 작은 크기
return this.handleNewWindow(
{
...config,
popupWidth: config.popupWidth || 600,
popupHeight: config.popupHeight || 400,
},
context,
);
}
/**
* 검색 액션 처리
*/
private static handleSearch(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { formData, onRefresh } = context;
console.log("검색 실행:", formData);
// 검색 조건 검증
const hasSearchCriteria = Object.values(formData).some(
(value) => value !== null && value !== undefined && value !== "",
);
if (!hasSearchCriteria) {
toast.warning("검색 조건을 입력해주세요.");
return false;
}
// 검색 실행 (데이터 새로고침)
onRefresh?.();
// 검색 조건을 URL 파라미터로 추가 (선택사항)
const searchParams = new URLSearchParams();
Object.entries(formData).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== "") {
searchParams.set(key, String(value));
}
});
// URL 업데이트 (히스토리에 추가하지 않음)
if (searchParams.toString()) {
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
window.history.replaceState({}, "", newUrl);
}
toast.success(config.successMessage || "검색을 실행했습니다.");
return true;
}
/**
* 추가 액션 처리
*/
private static handleAdd(config: ButtonActionConfig, context: ButtonActionContext): boolean {
console.log("추가 액션 실행:", context);
// 추가 로직 구현 (예: 새 레코드 생성 폼 열기)
return true;
}
/**
* 편집 액션 처리
*/
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
const { selectedRowsData, flowSelectedData } = context;
// 플로우 선택 데이터 우선 사용
let dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
console.log("🔍 handleEdit - 데이터 소스 확인:", {
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
flowSelectedDataLength: flowSelectedData?.length || 0,
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
selectedRowsDataLength: selectedRowsData?.length || 0,
dataToEditLength: dataToEdit?.length || 0,
});
// 선택된 데이터가 없는 경우
if (!dataToEdit || dataToEdit.length === 0) {
toast.error("수정할 항목을 선택해주세요.");
return false;
}
// 편집 화면이 설정되지 않은 경우
if (!config.targetScreenId) {
toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요.");
return false;
}
console.log(`📝 편집 액션 실행: ${dataToEdit.length}개 항목`, {
dataToEdit,
targetScreenId: config.targetScreenId,
editMode: config.editMode,
});
if (dataToEdit.length === 1) {
// 단일 항목 편집
const rowData = dataToEdit[0];
console.log("📝 단일 항목 편집:", rowData);
await this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
// TODO: 향후 다중 편집 지원
// console.log("📝 다중 항목 편집:", selectedRowsData);
// this.openBulkEditForm(config, selectedRowsData, context);
}
return true;
}
/**
* 편집 폼 열기 (단일 항목)
*/
private static async openEditForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
const editMode = config.editMode || "modal";
switch (editMode) {
case "modal":
// 모달로 편집 폼 열기
await this.openEditModal(config, rowData, context);
break;
case "navigate":
// 새 페이지로 이동
this.navigateToEditScreen(config, rowData, context);
break;
case "inline":
// 현재 화면에서 인라인 편집 (향후 구현)
toast.info("인라인 편집 기능은 향후 지원 예정입니다.");
break;
default:
// 기본값: 모달
this.openEditModal(config, rowData, context);
}
}
/**
* 편집 모달 열기
*/
private static async openEditModal(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<void> {
console.log("🎭 편집 모달 열기:", {
targetScreenId: config.targetScreenId,
modalSize: config.modalSize,
rowData,
});
// 1. config에 editModalDescription이 있으면 우선 사용
let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description && config.targetScreenId) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 모달 열기 이벤트 발생
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
modalSize: config.modalSize || "lg",
editData: rowData,
onSave: () => {
// 저장 후 테이블 새로고침
console.log("💾 편집 저장 완료 - 테이블 새로고침");
context.onRefresh?.();
},
},
});
window.dispatchEvent(modalEvent);
// 편집 모달 열기는 조용히 처리 (토스트 없음)
}
/**
* 편집 화면으로 이동
*/
private static navigateToEditScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
const rowId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
if (!rowId) {
toast.error("수정할 항목의 ID를 찾을 수 없습니다.");
return;
}
const editUrl = `/screens/${config.targetScreenId}?mode=edit&id=${rowId}`;
console.log("🔄 편집 화면으로 이동:", editUrl);
window.location.href = editUrl;
}
/**
* 복사 액션 처리 (품목코드 초기화)
*/
private static 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));
// 복사 시 제거할 필드들
const copiedData = { ...rowData };
const fieldsToRemove = [
// ID 필드 (새 레코드 생성)
"id",
"ID",
// 날짜 필드 (자동 생성)
"created_date",
"createdDate",
"updated_date",
"updatedDate",
"created_at",
"createdAt",
"updated_at",
"updatedAt",
"reg_date",
"regDate",
"mod_date",
"modDate",
];
// 제거할 필드 삭제
fieldsToRemove.forEach((field) => {
if (copiedData[field] !== undefined) {
delete copiedData[field];
console.log(`🗑️ 필드 제거: ${field}`);
}
});
// 품목코드 필드 초기화 (여러 가능한 필드명 확인)
const itemCodeFields = [
"item_code",
"itemCode",
"item_no",
"itemNo",
"item_number",
"itemNumber",
"품목코드",
"품번",
"code",
];
// 품목코드 필드를 찾아서 무조건 공백으로 초기화
let resetFieldName = "";
for (const field of itemCodeFields) {
if (copiedData[field] !== undefined) {
const originalValue = copiedData[field];
const ruleIdKey = `${field}_numberingRuleId`;
const hasNumberingRule = rowData[ruleIdKey] !== undefined && rowData[ruleIdKey] !== null && rowData[ruleIdKey] !== "";
// 품목코드를 무조건 공백으로 초기화
copiedData[field] = "";
// 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성)
if (hasNumberingRule) {
copiedData[ruleIdKey] = rowData[ruleIdKey];
console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`);
console.log(`📋 채번 규칙 ID 복사: ${ruleIdKey} = ${rowData[ruleIdKey]}`);
} else {
console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`);
}
resetFieldName = field;
break;
}
}
// 작성자 정보를 현재 사용자로 변경
const writerFields = ["writer", "creator", "reg_user", "regUser", "created_by", "createdBy"];
writerFields.forEach((field) => {
if (copiedData[field] !== undefined && context.userId) {
copiedData[field] = context.userId;
console.log(`👤 작성자 변경: ${field} = ${context.userId}`);
}
});
if (resetFieldName) {
toast.success(`복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.`);
} else {
console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다.");
console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData));
toast.info("복사본이 생성됩니다.");
}
console.log("📋 복사된 데이터:", copiedData);
await this.openCopyForm(config, copiedData, context);
} else {
// 다중 항목 복사 - 현재는 단일 복사만 지원
toast.error("현재 단일 항목 복사만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
}
return true;
} catch (error: any) {
console.error("❌ 복사 액션 실행 중 오류:", error);
toast.error(`복사 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`);
return false;
}
}
/**
* 복사 폼 열기 (단일 항목)
*/
private static async openCopyForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<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;
}
/**
* 닫기 액션 처리
*/
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) {
// 설정이 없으면 자동 판단 (우선순위 순서대로)
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
controlDataSource = "flow-selection";
console.log("🔄 자동 판단: flow-selection 모드 사용");
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
controlDataSource = "table-selection";
console.log("🔄 자동 판단: table-selection 모드 사용");
} else if (context.formData && Object.keys(context.formData).length > 0) {
controlDataSource = "form";
console.log("🔄 자동 판단: form 모드 사용");
} else {
controlDataSource = "form"; // 기본값
console.log("🔄 기본값: form 모드 사용");
}
}
console.log("📊 데이터 소스 모드:", {
controlDataSource,
hasFormData: !!(context.formData && Object.keys(context.formData).length > 0),
hasTableSelection: !!(context.selectedRowsData && context.selectedRowsData.length > 0),
hasFlowSelection: !!(context.flowSelectedData && context.flowSelectedData.length > 0),
});
const extendedContext: ExtendedControlContext = {
formData: context.formData || {},
selectedRows: context.selectedRows || [],
selectedRowsData: context.selectedRowsData || [],
flowSelectedData: context.flowSelectedData || [],
flowSelectedStepId: context.flowSelectedStepId,
controlDataSource,
};
console.log("🔍 제어 조건 검증 시작:", {
dataflowConfig: config.dataflowConfig,
extendedContext,
});
// 🔥 새로운 버튼 액션 실행 시스템 사용
if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) {
console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId, executionTiming } = config.dataflowConfig.flowConfig;
if (!flowId) {
console.error("❌ 플로우 ID가 없습니다");
toast.error("플로우가 설정되지 않았습니다.");
return false;
}
try {
// 노드 플로우 실행 API 호출 (API 클라이언트 사용)
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비: controlDataSource 설정 기반
let sourceData: any = null;
let dataSourceType: string = controlDataSource || "none";
console.log("🔍 데이터 소스 결정:", {
controlDataSource,
hasFlowSelectedData: !!(context.flowSelectedData && context.flowSelectedData.length > 0),
hasSelectedRowsData: !!(context.selectedRowsData && context.selectedRowsData.length > 0),
hasFormData: !!(context.formData && Object.keys(context.formData).length > 0),
});
// controlDataSource 설정에 따라 데이터 선택
switch (controlDataSource) {
case "flow-selection":
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
sourceData = context.flowSelectedData;
console.log("🌊 플로우 선택 데이터 사용:", {
stepId: context.flowSelectedStepId,
dataCount: sourceData.length,
sourceData,
});
} else {
console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다.");
}
break;
case "table-selection":
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
console.log("📊 테이블 선택 데이터 사용:", {
dataCount: sourceData.length,
sourceData,
});
} else {
console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다.");
}
break;
case "form":
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
console.log("📝 폼 데이터 사용:", sourceData);
} else {
console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다.");
}
break;
case "both":
// 폼 + 테이블 선택
sourceData = [];
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData.push(context.formData);
}
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData.push(...context.selectedRowsData);
}
console.log("🔀 폼 + 테이블 선택 데이터 사용:", {
dataCount: sourceData.length,
sourceData,
});
break;
default:
// 자동 판단 (설정이 없는 경우)
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
sourceData = context.flowSelectedData;
dataSourceType = "flow-selection";
console.log("🌊 [자동] 플로우 선택 데이터 사용");
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
dataSourceType = "table-selection";
console.log("📊 [자동] 테이블 선택 데이터 사용");
} else if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
dataSourceType = "form";
console.log("📝 [자동] 폼 데이터 사용");
}
break;
}
console.log("📦 최종 전달 데이터:", {
dataSourceType,
sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0,
sourceData,
});
const result = await executeNodeFlow(flowId, {
dataSourceType,
sourceData,
context,
});
if (result.success) {
console.log("✅ 노드 플로우 실행 완료:", result);
toast.success("플로우 실행이 완료되었습니다.");
// 플로우 새로고침 (플로우 위젯용)
if (context.onFlowRefresh) {
console.log("🔄 플로우 새로고침 호출");
context.onFlowRefresh();
}
// 테이블 새로고침 (일반 테이블용)
if (context.onRefresh) {
console.log("🔄 테이블 새로고침 호출");
context.onRefresh();
}
return true;
} else {
console.error("❌ 노드 플로우 실행 실패:", result);
toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다.");
return false;
}
} catch (error) {
console.error("❌ 노드 플로우 실행 오류:", error);
toast.error("플로우 실행 중 오류가 발생했습니다.");
return false;
}
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
let mergedFormData = { ...context.formData } || {};
if (
controlDataSource === "table-selection" &&
context.selectedRowsData &&
context.selectedRowsData.length > 0
) {
// 선택된 첫 번째 행의 데이터를 formData에 병합
const selectedRowData = context.selectedRowsData[0];
mergedFormData = { ...mergedFormData, ...selectedRowData };
console.log("🔄 선택된 행 데이터를 formData에 병합:", {
originalFormData: context.formData,
selectedRowData,
mergedFormData,
});
}
// 새로운 ImprovedButtonActionExecutor 사용
const buttonConfig = {
actionType: config.type,
dataflowConfig: config.dataflowConfig,
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
};
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, {
buttonId: context.buttonId || "unknown",
screenId: context.screenId || "unknown",
userId: context.userId || "unknown",
companyCode: context.companyCode || "*",
startTime: Date.now(),
contextData: context,
});
if (executionResult.success) {
console.log("✅ 관계 실행 완료:", executionResult);
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
} else {
console.error("❌ 관계 실행 실패:", executionResult);
toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다.");
return false;
}
} else {
// 제어 없음 - 성공 처리
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
// 새로고침이 필요한 경우
if (context.onRefresh) {
context.onRefresh();
}
return true;
}
} catch (error) {
console.error("제어 조건 검증 중 오류:", error);
toast.error("제어 조건 검증 중 오류가 발생했습니다.");
return false;
}
}
/**
* 저장 후 제어 실행 (After Timing)
*/
private static async executeAfterSaveControl(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<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,
},
);
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;
}
}
/**
* 테이블 이력 보기 액션 처리
*/
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) {
const { tableDisplayStore } = await import("@/stores/tableDisplayStore");
const storedData = tableDisplayStore.getTableData(context.tableName);
// 필터 조건은 저장소 또는 context에서 가져오기
const filterConditions = storedData?.filterConditions || context.filterConditions;
const searchTerm = storedData?.searchTerm || context.searchTerm;
try {
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const apiParams = {
page: 1,
size: 10000, // 최대 10,000개
sortBy: context.sortBy || storedData?.sortBy || "id",
sortOrder: (context.sortOrder || storedData?.sortOrder || "asc") as "asc" | "desc",
search: filterConditions, // ✅ 필터 조건
enableEntityJoin: true, // ✅ Entity 조인
// autoFilter는 entityJoinApi.getTableDataWithJoins 내부에서 자동으로 적용됨
};
// 🔒 멀티테넌시 준수: autoFilter로 company_code 자동 적용
const response = await entityJoinApi.getTableDataWithJoins(context.tableName, apiParams);
// 🔒 멀티테넌시 확인
const allData = Array.isArray(response) ? response : response?.data || [];
const companyCodesInData = [...new Set(allData.map((row: any) => row.company_code))];
if (companyCodesInData.length > 1) {
console.error("❌ 멀티테넌시 위반! 여러 회사의 데이터가 섞여있습니다:", companyCodesInData);
}
// entityJoinApi는 EntityJoinResponse 또는 data 배열을 반환
if (Array.isArray(response)) {
// 배열로 직접 반환된 경우
dataToExport = response;
} else if (response && 'data' in response) {
// EntityJoinResponse 객체인 경우
dataToExport = response.data;
} else {
console.error("❌ 예상치 못한 응답 형식:", response);
toast.error("데이터를 가져오는데 실패했습니다.");
return false;
}
} catch (error) {
console.error("엑셀 다운로드: 데이터 조회 실패:", error);
toast.error("데이터를 가져오는데 실패했습니다.");
return false;
}
}
// 폴백: 폼 데이터
else if (context.formData && Object.keys(context.formData).length > 0) {
dataToExport = [context.formData];
}
// 테이블명도 없고 폼 데이터도 없으면 에러
else {
toast.error("다운로드할 데이터 소스가 없습니다.");
return false;
}
// 배열이 아니면 배열로 변환
if (!Array.isArray(dataToExport)) {
if (typeof dataToExport === "object" && dataToExport !== null) {
dataToExport = [dataToExport];
} else {
toast.error("다운로드할 데이터 형식이 올바르지 않습니다.");
return false;
}
}
if (dataToExport.length === 0) {
toast.error("다운로드할 데이터가 없습니다.");
return false;
}
// 파일명 생성 (메뉴 이름 우선 사용)
let defaultFileName = context.tableName || "데이터";
// localStorage에서 메뉴 이름 가져오기
if (typeof window !== "undefined") {
const menuName = localStorage.getItem("currentMenuName");
if (menuName) {
defaultFileName = menuName;
}
}
const fileName = config.excelFileName || `${defaultFileName}_${new Date().toISOString().split("T")[0]}.xlsx`;
const sheetName = config.excelSheetName || "Sheet1";
const includeHeaders = config.excelIncludeHeaders !== false;
// 🎨 화면 레이아웃에서 테이블 리스트 컴포넌트의 컬럼 설정 가져오기
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;
});
}
// 최대 행 수 제한
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,
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,
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 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: "데이터 전달 중 오류가 발생했습니다.",
},
modal: {
type: "modal",
modalSize: "md",
},
edit: {
type: "edit",
successMessage: "편집되었습니다.",
},
control: {
type: "control",
},
view_table_history: {
type: "view_table_history",
historyRecordIdField: "id",
historyRecordIdSource: "selected_row",
},
excel_download: {
type: "excel_download",
excelIncludeHeaders: true,
successMessage: "엑셀 파일이 다운로드되었습니다.",
errorMessage: "엑셀 다운로드 중 오류가 발생했습니다.",
},
excel_upload: {
type: "excel_upload",
excelUploadMode: "insert",
confirmMessage: "엑셀 파일을 업로드하시겠습니까?",
successMessage: "엑셀 파일이 업로드되었습니다.",
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
},
barcode_scan: {
type: "barcode_scan",
barcodeFormat: "all",
barcodeAutoSubmit: false,
},
code_merge: {
type: "code_merge",
mergeShowPreview: true,
confirmMessage: "선택한 두 항목을 병합하시겠습니까?",
successMessage: "코드 병합이 완료되었습니다.",
errorMessage: "코드 병합 중 오류가 발생했습니다.",
},
};