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

4633 lines
178 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 { apiClient } from "@/lib/api/client";
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" // 코드 병합
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "transferData"; // 데이터 전달 (컴포넌트 간 or 화면 간)
/**
* 버튼 액션 설정
*/
export interface ButtonActionConfig {
type: ButtonActionType;
// 저장/제출 관련
saveEndpoint?: string;
validateForm?: boolean;
// 네비게이션 관련
targetUrl?: string;
targetScreenId?: number;
// 모달/팝업 관련
modalTitle?: string;
modalTitleBlocks?: Array<{
// 🆕 블록 기반 제목 (우선순위 높음)
id: string;
type: "text" | "field";
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
tableName?: string; // type=field일 때 테이블명
label?: string; // type=field일 때 표시용 라벨
}>;
modalDescription?: string;
modalSize?: "sm" | "md" | "lg" | "xl";
popupWidth?: number;
popupHeight?: number;
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
// 확인 메시지
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)
// 위치정보 관련
geolocationTableName?: string; // 위치정보 저장 테이블명 (기본: 현재 화면 테이블)
geolocationLatField?: string; // 위도를 저장할 필드명 (예: "latitude")
geolocationLngField?: string; // 경도를 저장할 필드명 (예: "longitude")
geolocationAccuracyField?: string; // 정확도를 저장할 필드명 (선택, 예: "accuracy")
geolocationTimestampField?: string; // 타임스탬프를 저장할 필드명 (선택, 예: "location_time")
geolocationHighAccuracy?: boolean; // 고정밀 모드 사용 여부 (기본: true)
geolocationTimeout?: number; // 타임아웃 (ms, 기본: 10000)
geolocationMaxAge?: number; // 캐시된 위치 최대 수명 (ms, 기본: 0)
geolocationAutoSave?: boolean; // 위치 가져온 후 자동 저장 여부 (기본: false)
geolocationKeyField?: string; // DB UPDATE 시 WHERE 조건에 사용할 키 필드 (예: "user_id")
geolocationKeySourceField?: string; // 키 값 소스 (예: "__userId__" 또는 폼 필드명)
geolocationUpdateField?: boolean; // 위치정보와 함께 추가 필드 변경 여부
geolocationExtraTableName?: string; // 추가 필드 변경 대상 테이블 (다른 테이블 가능)
geolocationExtraField?: string; // 추가로 변경할 필드명 (예: "status")
geolocationExtraValue?: string | number | boolean; // 추가로 변경할 값 (예: "active")
geolocationExtraKeyField?: string; // 다른 테이블의 키 필드 (예: "vehicle_id")
geolocationExtraKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id")
// 🆕 두 번째 테이블 설정 (위치정보 + 상태변경을 각각 다른 테이블에)
geolocationSecondTableEnabled?: boolean; // 두 번째 테이블 사용 여부
geolocationSecondTableName?: string; // 두 번째 테이블명 (예: "vehicles")
geolocationSecondMode?: "update" | "insert"; // 작업 모드 (기본: update)
geolocationSecondField?: string; // 두 번째 테이블에서 변경할 필드명 (예: "status")
geolocationSecondValue?: string | number | boolean; // 두 번째 테이블에서 변경할 값 (예: "inactive")
geolocationSecondKeyField?: string; // 두 번째 테이블의 키 필드 (예: "id") - UPDATE 모드에서만 사용
geolocationSecondKeySourceField?: string; // 현재 폼에서 키 값을 가져올 필드 (예: "vehicle_id") - UPDATE 모드에서만 사용
geolocationSecondInsertFields?: Record<string, any>; // INSERT 모드에서 추가로 넣을 필드들
// 🆕 연속 위치 추적 설정 (update_field 액션의 updateWithTracking 옵션용)
trackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
trackingTripIdField?: string; // 운행 ID를 저장할 필드명 (예: "trip_id")
trackingAutoGenerateTripId?: boolean; // 운행 ID 자동 생성 여부 (기본: true)
trackingDepartureField?: string; // 출발지 필드명 (formData에서 가져옴)
trackingArrivalField?: string; // 도착지 필드명 (formData에서 가져옴)
trackingVehicleIdField?: string; // 차량 ID 필드명 (formData에서 가져옴)
trackingStatusOnStart?: string; // 추적 시작 시 상태값 (예: "active")
trackingStatusOnStop?: string; // 추적 종료 시 상태값 (예: "completed")
trackingStatusField?: string; // 상태 필드명 (vehicles 테이블 등)
trackingStatusTableName?: string; // 상태 변경 대상 테이블명
trackingStatusKeyField?: string; // 상태 변경 키 필드 (예: "user_id")
trackingStatusKeySourceField?: string; // 키 값 소스 (예: "__userId__")
// 필드 값 교환 관련 (출발지 ↔ 목적지)
swapFieldA?: string; // 교환할 첫 번째 필드명 (예: "departure")
swapFieldB?: string; // 교환할 두 번째 필드명 (예: "destination")
swapRelatedFields?: Array<{ fieldA: string; fieldB: string }>; // 함께 교환할 관련 필드들 (예: 위도/경도)
// 필드 값 변경 관련 (특정 필드를 특정 값으로 변경)
updateTargetField?: string; // 변경할 필드명 (예: "status")
updateTargetValue?: string | number | boolean; // 변경할 값 (예: "active")
updateAutoSave?: boolean; // 변경 후 자동 저장 여부 (기본: true)
updateMultipleFields?: Array<{ field: string; value: string | number | boolean }>; // 여러 필드 동시 변경
updateTableName?: string; // 대상 테이블명 (다른 테이블 UPDATE 시)
updateKeyField?: string; // 키 필드명 (WHERE 조건에 사용)
updateKeySourceField?: string; // 키 값 소스 (폼 필드명 또는 __userId__ 등 특수 키워드)
// 🆕 필드 값 변경 + 위치정보 수집 (update_field 액션에서 사용)
updateWithGeolocation?: boolean; // 위치정보도 함께 수집할지 여부
updateGeolocationLatField?: string; // 위도 저장 필드
updateGeolocationLngField?: string; // 경도 저장 필드
// 🆕 필드 값 변경 + 연속 위치 추적 (update_field 액션에서 사용)
updateWithTracking?: boolean; // 연속 위치 추적 사용 여부
updateTrackingMode?: "start" | "stop"; // 추적 모드 (시작/종료)
updateTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000)
updateGeolocationAccuracyField?: string; // 정확도 저장 필드 (선택)
updateGeolocationTimestampField?: string; // 타임스탬프 저장 필드 (선택)
// 🆕 공차등록 연속 위치 추적 설정 (empty_vehicle 액션에서 사용)
emptyVehicleTracking?: boolean; // 공차 상태에서 연속 위치 추적 여부 (기본: true)
emptyVehicleTrackingInterval?: number; // 위치 저장 주기 (ms, 기본: 10000 = 10초)
// 편집 관련 (수주관리 등 그룹별 다중 레코드 편집)
editMode?: "modal" | "navigate" | "inline"; // 편집 모드
editModalTitle?: string; // 편집 모달 제목
editModalDescription?: string; // 편집 모달 설명
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
// 데이터 전달 관련 (transferData 액션용)
dataTransfer?: {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
// 데이터 매핑 규칙
mappingRules: Array<{
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
defaultValue?: any; // 기본값
}>;
// 전달 옵션
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
};
}
/**
* 버튼 액션 실행 컨텍스트
*/
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; // 플로우 새로고침 콜백
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
flowSelectedData?: any[];
flowSelectedStepId?: number | null;
// 🆕 같은 화면의 모든 컴포넌트 (TableList 자동 감지용)
allComponents?: any[];
// 제어 실행을 위한 추가 정보
buttonId?: string;
// 테이블 정렬 정보 (엑셀 다운로드용)
sortBy?: string; // 정렬 컬럼명
sortOrder?: "asc" | "desc"; // 정렬 방향
columnOrder?: string[]; // 컬럼 순서 (사용자가 드래그앤드롭으로 변경한 순서)
tableDisplayData?: any[]; // 화면에 표시된 데이터 (정렬 및 컬럼 순서 적용됨)
// 🆕 엑셀 다운로드 개선을 위한 추가 필드
filterConditions?: Record<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 → 컴포넌트 설정
// 🆕 분할 패널 부모 데이터 (좌측 화면에서 선택된 데이터)
splitPanelParentData?: Record<string, any>;
}
/**
* 🆕 특수 키워드를 실제 값으로 변환하는 헬퍼 함수
* 지원하는 키워드:
* - __userId__ : 로그인한 사용자 ID
* - __userName__ : 로그인한 사용자 이름
* - __companyCode__ : 로그인한 사용자의 회사 코드
* - __screenId__ : 현재 화면 ID
* - __tableName__ : 현재 테이블명
*/
export function resolveSpecialKeyword(
sourceField: string | undefined,
context: ButtonActionContext
): any {
if (!sourceField) return undefined;
// 특수 키워드 처리
switch (sourceField) {
case "__userId__":
console.log("🔑 특수 키워드 변환: __userId__ →", context.userId);
return context.userId;
case "__userName__":
console.log("🔑 특수 키워드 변환: __userName__ →", context.userName);
return context.userName;
case "__companyCode__":
console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode);
return context.companyCode;
case "__screenId__":
console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId);
return context.screenId;
case "__tableName__":
console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName);
return context.tableName;
default:
// 일반 폼 데이터에서 가져오기
return context.formData?.[sourceField];
}
}
/**
* 버튼 액션 실행기
*/
export class ButtonActionExecutor {
/**
* 액션 실행
*/
static async executeAction(config: ButtonActionConfig, context: ButtonActionContext): Promise<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);
case "transferData":
return await this.handleTransferData(config, context);
// case "empty_vehicle":
// return await this.handleEmptyVehicle(config, context);
case "operation_control":
return await this.handleOperationControl(config, context);
case "swap_fields":
return await this.handleSwapFields(config, context);
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, onSave } = context;
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];
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
isArray: Array.isArray(value),
length: Array.isArray(value) ? value.length : 0,
firstItem:
Array.isArray(value) && value.length > 0
? {
keys: Object.keys(value[0] || {}),
hasOriginalData: !!value[0]?.originalData,
hasFieldGroups: !!value[0]?.fieldGroups,
actualValue: value[0],
}
: null,
});
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
});
if (selectedItemsKeys.length > 0) {
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
return await this.handleBatchSave(config, context, selectedItemsKeys);
} else {
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
}
// 폼 유효성 검사
if (config.validateForm) {
const validation = this.validateFormData(formData);
if (!validation.isValid) {
toast.error(`입력값을 확인해주세요: ${validation.errors.join(", ")}`);
return false;
}
}
try {
// API 엔드포인트가 지정된 경우
if (config.saveEndpoint) {
const response = await fetch(config.saveEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error(`저장 실패: ${response.statusText}`);
}
} else if (tableName && screenId) {
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
if (!primaryKeyResult.success) {
throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다.");
}
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
const isUpdate = hasRealOriginalData && !!primaryKeyValue;
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
hasOriginalData: !!originalData,
hasRealOriginalData,
originalDataKeys: originalData ? Object.keys(originalData) : [],
primaryKeyValue,
isUpdate,
primaryKeys,
});
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,
// formDataCompanyCode: formData.company_code,
// defaultWriterValue: writerValue,
// companyCodeValue,
// });
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
console.log("🔍 채번 규칙 할당 체크 시작");
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);
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 채번 규칙 할당 시작 (allocateCode 호출)");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`${fieldName} 새 코드 할당: ${formData[fieldName]}${newCode}`);
formData[fieldName] = newCode;
} else {
console.warn(`⚠️ ${fieldName} 코드 할당 실패, 기존 값 유지:`, allocateResult.error);
}
} catch (allocateError) {
console.error(`${fieldName} 코드 할당 오류:`, allocateError);
// 오류 시 기존 값 유지
}
}
}
console.log("✅ 채번 규칙 할당 완료");
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
const splitPanelData = context.splitPanelParentData || {};
if (Object.keys(splitPanelData).length > 0) {
console.log("🔗 [handleSave] 분할 패널 부모 데이터 병합:", splitPanelData);
}
const dataWithUserInfo = {
...splitPanelData, // 분할 패널 부모 데이터 먼저 적용
...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];
}
}
// 🆕 반복 필드 그룹에서 삭제된 항목 처리
// 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);
}
}
}
}
}
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
const repeatScreenModalKeys = Object.keys(context.formData).filter((key) =>
key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations"
);
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
const shouldSkipMainSave = repeatScreenModalTables.includes(tableName);
if (shouldSkipMainSave) {
console.log(`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeatScreenModal에서 처리)`);
saveResult = { success: true, message: "RepeatScreenModal에서 처리" };
} else {
saveResult = await DynamicFormApi.saveFormData({
screenId,
tableName,
data: dataWithUserInfo,
});
}
if (repeatScreenModalKeys.length > 0) {
console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys);
// 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no)
const numberingFields: Record<string, any> = {};
for (const [fieldKey, value] of Object.entries(context.formData)) {
// _numberingRuleId로 끝나는 키가 있으면 해당 필드는 채번 규칙 값
if (context.formData[`${fieldKey}_numberingRuleId`]) {
numberingFields[fieldKey] = value;
}
}
console.log("📦 [handleSave] 채번 규칙 필드:", numberingFields);
for (const key of repeatScreenModalKeys) {
const targetTable = key.replace("_repeatScreenModal_", "");
const rows = context.formData[key] as any[];
if (!Array.isArray(rows) || rows.length === 0) continue;
console.log(`📦 [handleSave] ${targetTable} 테이블 저장:`, rows);
for (const row of rows) {
const { _isNew, _targetTable, id, ...dataToSave } = row;
// 사용자 정보 추가 + 채번 규칙 값 병합
const dataWithMeta = {
...dataToSave,
...numberingFields, // 채번 규칙 값 (shipment_plan_no 등)
created_by: context.userId,
updated_by: context.userId,
company_code: context.companyCode,
};
try {
if (_isNew) {
// INSERT
console.log(`📝 [handleSave] ${targetTable} INSERT:`, dataWithMeta);
const insertResult = await apiClient.post(
`/table-management/tables/${targetTable}/add`,
dataWithMeta
);
console.log(`✅ [handleSave] ${targetTable} INSERT 완료:`, insertResult.data);
} else if (id) {
// UPDATE
const originalData = { id };
const updatedData = { ...dataWithMeta, id };
console.log(`📝 [handleSave] ${targetTable} UPDATE:`, { originalData, updatedData });
const updateResult = await apiClient.put(
`/table-management/tables/${targetTable}/edit`,
{ originalData, updatedData }
);
console.log(`✅ [handleSave] ${targetTable} UPDATE 완료:`, updateResult.data);
}
} catch (error: any) {
console.error(`❌ [handleSave] ${targetTable} 저장 실패:`, error.response?.data || error.message);
// 개별 실패는 전체 저장을 중단하지 않음
}
}
}
}
// 🆕 v3.9: RepeatScreenModal 집계 저장 처리
const aggregationConfigs = context.formData._repeatScreenModal_aggregations as Array<{
resultField: string;
aggregatedValue: number;
targetTable: string;
targetColumn: string;
joinKey: { sourceField: string; targetField: string };
sourceValue: any;
}>;
if (aggregationConfigs && aggregationConfigs.length > 0) {
console.log("📊 [handleSave] 집계 저장 시작:", aggregationConfigs);
for (const config of aggregationConfigs) {
const { targetTable, targetColumn, joinKey, aggregatedValue, sourceValue } = config;
try {
const originalData = { [joinKey.targetField]: sourceValue };
const updatedData = {
[targetColumn]: aggregatedValue,
[joinKey.targetField]: sourceValue,
};
console.log(`📊 [handleSave] ${targetTable}.${targetColumn} = ${aggregatedValue} (조인: ${joinKey.sourceField} = ${sourceValue})`);
const updateResult = await apiClient.put(
`/table-management/tables/${targetTable}/edit`,
{ originalData, updatedData }
);
console.log(`✅ [handleSave] ${targetTable} 집계 저장 완료:`, updateResult.data);
} catch (error: any) {
console.error(`❌ [handleSave] ${targetTable} 집계 저장 실패:`, error.response?.data || error.message);
}
}
}
}
if (!saveResult.success) {
throw new Error(saveResult.message || "저장에 실패했습니다.");
}
// 🔥 저장 성공 후 연결된 제어 실행 (dataflowTiming이 'after'인 경우)
if (config.enableDataflowControl && config.dataflowConfig) {
console.log("🎯 저장 후 제어 실행 시작:", config.dataflowConfig);
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);
}
/**
* 🆕 배치 저장 액션 처리 (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;
}
}
/**
* 삭제 액션 처리
*/
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?.(); // 테이블 새로고침
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log("🔄 refreshTable 전역 이벤트 발생");
}
toast.success(config.successMessage || `${dataToDelete.length}개 항목이 삭제되었습니다.`);
return true;
}
// 단일 삭제 (기존 로직)
if (tableName && screenId && formData.id) {
console.log("단일 데이터 삭제:", { tableName, screenId, id: formData.id });
// 실제 삭제 API 호출
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?.();
// 🆕 분할 패널 등 전역 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTable"));
console.log("🔄 refreshTable 전역 이벤트 발생 (단일 삭제)");
toast.success(config.successMessage || "삭제되었습니다.");
return true;
} catch (error) {
console.error("삭제 오류:", error);
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
}
}
/**
* 초기화 액션 처리
*/
private static handleReset(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { formData, onFormDataChange } = context;
// 폼 데이터 초기화 - 각 필드를 개별적으로 초기화
if (onFormDataChange && formData) {
Object.keys(formData).forEach((key) => {
onFormDataChange(key, "");
});
}
toast.success(config.successMessage || "초기화되었습니다.");
return true;
}
/**
* 취소 액션 처리
*/
private static handleCancel(config: ButtonActionConfig, context: ButtonActionContext): boolean {
const { onClose } = context;
onClose?.();
return true;
}
/**
* 네비게이션 액션 처리
*/
private static handleNavigate(config: ButtonActionConfig, context: ButtonActionContext): boolean {
let targetUrl = config.targetUrl;
// 화면 ID가 지정된 경우 URL 생성
if (config.targetScreenId) {
targetUrl = `/screens/${config.targetScreenId}`;
}
if (targetUrl) {
window.location.href = targetUrl;
return true;
}
toast.error("이동할 페이지가 지정되지 않았습니다.");
return false;
}
/**
* 모달 액션 처리
*/
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
// 모달 열기 로직
console.log("모달 열기:", {
title: config.modalTitle,
size: config.modalSize,
targetScreenId: config.targetScreenId,
selectedRowsData: context.selectedRowsData,
});
if (config.targetScreenId) {
// 1. config에 modalDescription이 있으면 우선 사용
let description = config.modalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
if (!description) {
try {
const screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || "";
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 🆕 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
console.log("📦 [handleModal] 분할 패널 부모 데이터:", context.splitPanelParentData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.modalTitle || "화면",
description: description,
size: config.modalSize || "md",
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
// 🆕 분할 패널 부모 데이터 전달 (탭 안 모달에서 사용)
splitPanelParentData: context.splitPanelParentData || {},
},
});
window.dispatchEvent(modalEvent);
// 모달 열기는 조용히 처리 (토스트 불필요)
} else {
console.error("모달로 열 화면이 지정되지 않았습니다.");
return false;
}
return true;
}
/**
* 🆕 데이터를 전달하면서 모달 열기 액션 처리
*/
private static async handleOpenModalWithData(
config: ButtonActionConfig,
context: ButtonActionContext,
): Promise<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);
}
}
// 🆕 부모 화면의 선택된 데이터 가져오기 (excludeFilter에서 사용)
const parentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
dataSourceId,
parentData,
});
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
const modalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: finalTitle, // 🆕 동적 제목 사용
description: description,
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
},
});
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;
// 선택된 데이터가 없는 경우
if (!dataToEdit || dataToEdit.length === 0) {
toast.error("수정할 항목을 선택해주세요.");
return false;
}
// 편집 화면이 설정되지 않은 경우
if (!config.targetScreenId) {
toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요.");
return false;
}
if (dataToEdit.length === 1) {
// 단일 항목 편집
const rowData = dataToEdit[0];
await this.openEditForm(config, rowData, context);
} else {
// 다중 항목 편집 - 현재는 단일 편집만 지원
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
return false;
}
return true;
}
/**
* 편집 폼 열기 (단일 항목)
*/
private static async openEditForm(
config: ButtonActionConfig,
rowData: any,
context: ButtonActionContext,
): Promise<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:
// 기본값: 모달
await this.openEditModal(config, rowData, context);
}
}
/**
* 편집 모달 열기
*/
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];
}
// 1. config에 editModalDescription이 있으면 우선 사용
let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기
let screenInfo: any = null;
if (config.targetScreenId) {
try {
screenInfo = await screenApi.getScreen(config.targetScreenId);
if (!description) {
description = screenInfo?.description || "";
}
} catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error);
}
}
// 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지)
let hasSplitPanel = false;
if (config.targetScreenId) {
try {
const layoutData = await screenApi.getLayout(config.targetScreenId);
if (layoutData?.components) {
hasSplitPanel = layoutData.components.some(
(comp: any) =>
comp.type === "screen-split-panel" ||
comp.componentType === "screen-split-panel" ||
comp.type === "split-panel-layout" ||
comp.componentType === "split-panel-layout",
);
}
console.log("🔍 [openEditModal] 분할 패널 확인:", {
targetScreenId: config.targetScreenId,
hasSplitPanel,
componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
});
} catch (error) {
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
}
}
// 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달)
if (hasSplitPanel) {
console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용");
const screenModalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달
},
});
window.dispatchEvent(screenModalEvent);
return;
}
// 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리)
const modalEvent = new CustomEvent("openEditModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
modalSize: config.modalSize || "lg",
editData: rowData,
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
tableName: context.tableName, // 🆕 테이블명 전달
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));
// 복사 시 제거할 필드들
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,
});
// 🔥 새로운 버튼 액션 실행 시스템 사용
// flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig;
if (isFlowMode && config.dataflowConfig?.flowConfig) {
console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig);
const { flowId, executionTiming } = config.dataflowConfig.flowConfig;
if (!flowId) {
console.error("❌ 플로우 ID가 없습니다");
toast.error("플로우가 설정되지 않았습니다.");
return false;
}
try {
// 노드 플로우 실행 API 호출 (API 클라이언트 사용)
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
// 데이터 소스 준비: controlDataSource 설정 기반
let sourceData: any = null;
let dataSourceType: string = controlDataSource || "none";
console.log("🔍 데이터 소스 결정:", {
controlDataSource,
hasFlowSelectedData: !!(context.flowSelectedData && context.flowSelectedData.length > 0),
hasSelectedRowsData: !!(context.selectedRowsData && context.selectedRowsData.length > 0),
hasFormData: !!(context.formData && Object.keys(context.formData).length > 0),
});
// controlDataSource 설정에 따라 데이터 선택
switch (controlDataSource) {
case "flow-selection":
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
sourceData = context.flowSelectedData;
console.log("🌊 플로우 선택 데이터 사용:", {
stepId: context.flowSelectedStepId,
dataCount: sourceData.length,
sourceData,
});
} else {
console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다.");
toast.error("플로우에서 데이터를 먼저 선택해주세요.");
return false;
}
break;
case "table-selection":
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
sourceData = context.selectedRowsData;
console.log("📊 테이블 선택 데이터 사용:", {
dataCount: sourceData.length,
sourceData,
});
} else {
console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다.");
toast.error("테이블에서 처리할 항목을 먼저 선택해주세요.");
return false;
}
break;
case "form":
if (context.formData && Object.keys(context.formData).length > 0) {
sourceData = [context.formData];
console.log("📝 폼 데이터 사용:", sourceData);
} else {
console.warn("⚠️ form 모드이지만 폼 데이터가 없습니다.");
}
break;
case "both":
// 폼 + 테이블 선택
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 trackingIntervalId: NodeJS.Timeout | null = null;
private static currentTripId: string | null = null;
private static trackingContext: ButtonActionContext | null = null;
private static trackingConfig: ButtonActionConfig | null = null;
/**
* 연속 위치 추적 시작
*/
private static async handleTrackingStart(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🚀 [handleTrackingStart] 위치 추적 시작:", { config, context });
// 이미 추적 중인지 확인
if (this.trackingIntervalId) {
toast.warning("이미 위치 추적이 진행 중입니다.");
return false;
}
// 위치 권한 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치 정보를 지원하지 않습니다.");
return false;
}
// Trip ID 생성
const tripId = config.trackingAutoGenerateTripId !== false
? `TRIP_${Date.now()}_${context.userId || "unknown"}`
: context.formData?.[config.trackingTripIdField || "trip_id"] || `TRIP_${Date.now()}`;
this.currentTripId = tripId;
this.trackingContext = context;
this.trackingConfig = config;
// 출발지/도착지 정보
const departure = context.formData?.[config.trackingDepartureField || "departure"] || null;
const arrival = context.formData?.[config.trackingArrivalField || "arrival"] || null;
const departureName = context.formData?.["departure_name"] || null;
const destinationName = context.formData?.["destination_name"] || null;
const vehicleId = context.formData?.[config.trackingVehicleIdField || "vehicle_id"] || null;
console.log("📍 [handleTrackingStart] 운행 정보:", {
tripId,
departure,
arrival,
departureName,
destinationName,
vehicleId,
});
// 상태 변경 (vehicles 테이블 등)
if (config.trackingStatusOnStart && config.trackingStatusField) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = config.trackingStatusTableName || context.tableName;
const keyField = config.trackingStatusKeyField || "user_id";
const keyValue = resolveSpecialKeyword(config.trackingStatusKeySourceField || "__userId__", context);
if (keyValue) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: config.trackingStatusField,
updateValue: config.trackingStatusOnStart,
});
console.log("✅ 상태 변경 완료:", config.trackingStatusOnStart);
}
} catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError);
}
}
// 첫 번째 위치 저장
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
// 주기적 위치 저장 시작
const interval = config.trackingInterval || 10000; // 기본 10초
this.trackingIntervalId = setInterval(async () => {
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
}, interval);
toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`);
// 추적 시작 이벤트 발생 (UI 업데이트용)
window.dispatchEvent(new CustomEvent("trackingStarted", {
detail: { tripId, interval }
}));
return true;
} catch (error: any) {
console.error("❌ 위치 추적 시작 실패:", error);
toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다.");
return false;
}
}
/**
* 연속 위치 추적 종료
*/
private static async handleTrackingStop(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
// 추적 중인지 확인
if (!this.trackingIntervalId) {
toast.warning("진행 중인 위치 추적이 없습니다.");
return false;
}
// 타이머 정리
clearInterval(this.trackingIntervalId);
this.trackingIntervalId = null;
const tripId = this.currentTripId;
// 마지막 위치 저장 (trip_status를 completed로)
const departure = this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null;
const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
const vehicleId = this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null;
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId, "completed");
// 상태 변경 (vehicles 테이블 등)
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig;
const effectiveContext = context.userId ? context : this.trackingContext;
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
try {
const { apiClient } = await import("@/lib/api/client");
const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName;
const keyField = effectiveConfig.trackingStatusKeyField || "user_id";
const keyValue = resolveSpecialKeyword(effectiveConfig.trackingStatusKeySourceField || "__userId__", effectiveContext);
if (keyValue) {
await apiClient.put(`/dynamic-form/update-field`, {
tableName: statusTableName,
keyField: keyField,
keyValue: keyValue,
updateField: effectiveConfig.trackingStatusField,
updateValue: effectiveConfig.trackingStatusOnStop,
});
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
}
} catch (statusError) {
console.warn("⚠️ 상태 변경 실패:", statusError);
}
}
// 컨텍스트 정리
this.currentTripId = null;
this.trackingContext = null;
this.trackingConfig = null;
toast.success(config.successMessage || "위치 추적이 종료되었습니다.");
// 추적 종료 이벤트 발생 (UI 업데이트용)
window.dispatchEvent(new CustomEvent("trackingStopped", {
detail: { tripId }
}));
// 화면 새로고침
context.onRefresh?.();
return true;
} catch (error: any) {
console.error("❌ 위치 추적 종료 실패:", error);
toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다.");
return false;
}
}
/**
* 위치 이력 테이블에 저장 (내부 헬퍼)
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
*/
private static async saveLocationToHistory(
tripId: string | null,
departure: string | null,
arrival: string | null,
departureName: string | null,
destinationName: string | null,
vehicleId: number | null,
tripStatus: string = "active"
): Promise<void> {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
const { apiClient } = await import("@/lib/api/client");
const { latitude, longitude, accuracy, altitude, speed, heading } = position.coords;
const locationData = {
latitude,
longitude,
accuracy,
altitude,
speed,
heading,
tripId,
tripStatus,
departure,
arrival,
departureName,
destinationName,
recordedAt: new Date(position.timestamp).toISOString(),
vehicleId,
};
console.log("📍 [saveLocationToHistory] 위치 저장:", locationData);
// 1. vehicle_location_history에 저장
const response = await apiClient.post(`/dynamic-form/location-history`, locationData);
if (response.data?.success) {
console.log("✅ 위치 이력 저장 성공:", response.data.data);
} else {
console.warn("⚠️ 위치 이력 저장 실패:", response.data);
}
// 2. vehicles 테이블의 latitude/longitude도 업데이트 (실시간 위치 반영)
if (this.trackingContext && this.trackingConfig) {
const keyField = this.trackingConfig.trackingStatusKeyField || "user_id";
const keySourceField = this.trackingConfig.trackingStatusKeySourceField || "__userId__";
const keyValue = resolveSpecialKeyword(keySourceField, this.trackingContext);
const vehiclesTableName = this.trackingConfig.trackingStatusTableName || "vehicles";
if (keyValue) {
try {
// latitude 업데이트
await apiClient.put(`/dynamic-form/update-field`, {
tableName: vehiclesTableName,
keyField,
keyValue,
updateField: "latitude",
updateValue: latitude,
});
// longitude 업데이트
await apiClient.put(`/dynamic-form/update-field`, {
tableName: vehiclesTableName,
keyField,
keyValue,
updateField: "longitude",
updateValue: longitude,
});
console.log(`✅ vehicles 테이블 위치 업데이트: (${latitude.toFixed(6)}, ${longitude.toFixed(6)})`);
} catch (vehicleUpdateError) {
// 컬럼이 없으면 조용히 무시
console.warn("⚠️ vehicles 테이블 위치 업데이트 실패 (무시):", vehicleUpdateError);
}
}
}
resolve();
} catch (error) {
console.error("❌ 위치 이력 저장 오류:", error);
reject(error);
}
},
(error) => {
console.error("❌ 위치 획득 실패:", error.message);
reject(error);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
}
);
});
}
/**
* 현재 추적 상태 확인 (외부에서 호출 가능)
*/
static isTracking(): boolean {
return this.trackingIntervalId !== null;
}
/**
* 현재 Trip ID 가져오기 (외부에서 호출 가능)
*/
static getCurrentTripId(): string | null {
return this.currentTripId;
}
/**
* 데이터 전달 액션 처리 (분할 패널에서 좌측 → 우측 데이터 전달)
*/
private static async handleTransferData(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📤 [handleTransferData] 데이터 전달 시작:", { config, context });
// 선택된 행 데이터 확인
const selectedRows = context.selectedRowsData || context.flowSelectedData || [];
if (!selectedRows || selectedRows.length === 0) {
toast.error("전달할 데이터를 선택해주세요.");
return false;
}
console.log("📤 [handleTransferData] 선택된 데이터:", selectedRows);
// dataTransfer 설정 확인
const dataTransfer = config.dataTransfer;
if (!dataTransfer) {
// dataTransfer 설정이 없으면 기본 동작: 전역 이벤트로 데이터 전달
console.log("📤 [handleTransferData] dataTransfer 설정 없음 - 전역 이벤트 발생");
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
detail: {
data: selectedRows,
mode: "append",
sourcePosition: "left",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
}
// dataTransfer 설정이 있는 경우
const { targetType, targetComponentId, targetScreenId, mappingRules, receiveMode } = dataTransfer;
if (targetType === "component" && targetComponentId) {
// 같은 화면 내 컴포넌트로 전달
console.log("📤 [handleTransferData] 컴포넌트로 전달:", targetComponentId);
const transferEvent = new CustomEvent("componentDataTransfer", {
detail: {
targetComponentId,
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
} else if (targetType === "screen" && targetScreenId) {
// 다른 화면으로 전달 (분할 패널 등)
console.log("📤 [handleTransferData] 화면으로 전달:", targetScreenId);
const transferEvent = new CustomEvent("screenDataTransfer", {
detail: {
targetScreenId,
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
} else {
// 기본: 분할 패널 데이터 전달 이벤트
console.log("📤 [handleTransferData] 기본 분할 패널 전달");
const transferEvent = new CustomEvent("splitPanelDataTransfer", {
detail: {
data: selectedRows,
mappingRules,
mode: receiveMode || "append",
sourcePosition: "left",
},
});
window.dispatchEvent(transferEvent);
toast.success(`${selectedRows.length}개 항목이 전달되었습니다.`);
return true;
}
} catch (error: any) {
console.error("❌ 데이터 전달 실패:", error);
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
return false;
}
}
// 공차 추적용 watchId 저장
private static emptyVehicleWatchId: number | null = null;
private static emptyVehicleTripId: string | null = null;
/**
* 공차등록 액션 처리
* - 위치 수집 + 상태 변경 (예: status → inactive)
* - 연속 위치 추적 시작 (vehicle_location_history에 저장)
*/
private static async handleEmptyVehicle(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("📍 공차등록 액션 실행:", { config, context });
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 위도/경도 저장 필드 확인
const latField = config.geolocationLatField || "latitude";
const lngField = config.geolocationLngField || "longitude";
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
// Geolocation 옵션 설정
const options: PositionOptions = {
enableHighAccuracy: config.geolocationHighAccuracy !== false,
timeout: config.geolocationTimeout || 10000,
maximumAge: config.geolocationMaxAge || 0,
};
// 위치 정보 가져오기
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, options);
});
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득 성공:", { latitude, longitude, accuracy });
// 폼 데이터 업데이트
const updates: Record<string, any> = {
[latField]: latitude,
[lngField]: longitude,
};
if (config.geolocationAccuracyField && accuracy !== null) {
updates[config.geolocationAccuracyField] = accuracy;
}
if (config.geolocationTimestampField) {
updates[config.geolocationTimestampField] = timestamp.toISOString();
}
// onFormDataChange로 폼 업데이트
if (context.onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
context.onFormDataChange!(field, value);
});
}
// 🆕 자동 저장 옵션이 활성화된 경우 DB UPDATE
if (config.geolocationAutoSave) {
const keyField = config.geolocationKeyField || "user_id";
const keySourceField = config.geolocationKeySourceField || "__userId__";
const keyValue = resolveSpecialKeyword(keySourceField, context);
const targetTableName = config.geolocationTableName || context.tableName;
if (keyValue && targetTableName) {
try {
const { apiClient } = await import("@/lib/api/client");
// 위치 정보 필드들 업데이트 (위도, 경도, 정확도, 타임스탬프)
const fieldsToUpdate = { ...updates };
// formData에서 departure, arrival만 포함 (테이블에 있을 가능성 높은 필드만)
if (context.formData?.departure) fieldsToUpdate.departure = context.formData.departure;
if (context.formData?.arrival) fieldsToUpdate.arrival = context.formData.arrival;
// 추가 필드 변경 (status 등)
if (config.geolocationExtraField && config.geolocationExtraValue !== undefined) {
fieldsToUpdate[config.geolocationExtraField] = config.geolocationExtraValue;
}
console.log("📍 DB UPDATE 시작:", { targetTableName, keyField, keyValue, fieldsToUpdate });
// 각 필드를 개별적으로 UPDATE (에러 무시)
let successCount = 0;
for (const [field, value] of Object.entries(fieldsToUpdate)) {
try {
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: targetTableName,
keyField,
keyValue,
updateField: field,
updateValue: value,
});
if (response.data?.success) {
successCount++;
}
} catch {
// 컬럼이 없으면 조용히 무시 (에러 로그 안 찍음)
}
}
console.log(`📍 DB UPDATE 완료: ${successCount}/${Object.keys(fieldsToUpdate).length} 필드 저장됨`);
// 🆕 연속 위치 추적 시작 (공차 상태에서도 위치 기록)
if (config.emptyVehicleTracking !== false) {
await this.startEmptyVehicleTracking(config, context, {
latitude, longitude, accuracy, speed, heading, altitude
});
}
toast.success(config.successMessage || "공차 등록이 완료되었습니다. 위치 추적을 시작합니다.");
} catch (saveError) {
console.error("❌ 위치정보 자동 저장 실패:", saveError);
toast.error("위치 정보 저장에 실패했습니다.");
return false;
}
} else {
console.warn("⚠️ 키 값 또는 테이블명이 없어서 자동 저장을 건너뜁니다:", { keyValue, targetTableName });
toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`);
}
} else {
// 자동 저장 없이 성공 메시지만
toast.success(config.successMessage || `위치: ${latitude.toFixed(6)}, ${longitude.toFixed(6)}`);
}
return true;
} catch (error: any) {
console.error("❌ 공차등록 실패:", error);
toast.dismiss();
// GeolocationPositionError 처리
if (error.code) {
switch (error.code) {
case 1: // PERMISSION_DENIED
toast.error("위치 정보 접근이 거부되었습니다.\n브라우저 설정에서 위치 권한을 허용해주세요.");
break;
case 2: // POSITION_UNAVAILABLE
toast.error("위치 정보를 사용할 수 없습니다.\nGPS 신호를 확인해주세요.");
break;
case 3: // TIMEOUT
toast.error("위치 정보 요청 시간이 초과되었습니다.\n다시 시도해주세요.");
break;
default:
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
}
} else {
toast.error(config.errorMessage || "위치 정보를 가져오는 중 오류가 발생했습니다.");
}
return false;
}
}
/**
* 공차 상태에서 연속 위치 추적 시작
*/
private static async startEmptyVehicleTracking(
config: ButtonActionConfig,
context: ButtonActionContext,
initialPosition: { latitude: number; longitude: number; accuracy: number | null; speed: number | null; heading: number | null; altitude: number | null }
): Promise<void> {
try {
// 기존 추적이 있으면 중지
if (this.emptyVehicleWatchId !== null) {
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
this.emptyVehicleWatchId = null;
}
const { apiClient } = await import("@/lib/api/client");
// Trip ID 생성 (공차용)
const tripId = `EMPTY-${Date.now()}-${Math.random().toString(36).substring(2, 10)}`;
this.emptyVehicleTripId = tripId;
const userId = context.userId || "";
const companyCode = context.companyCode || "";
const departure = context.formData?.departure || "";
const arrival = context.formData?.arrival || "";
const departureName = context.formData?.departure_name || "";
const destinationName = context.formData?.destination_name || "";
// 시작 위치 기록
try {
await apiClient.post("/dynamic-form/location-history", {
tripId,
userId,
latitude: initialPosition.latitude,
longitude: initialPosition.longitude,
accuracy: initialPosition.accuracy,
speed: initialPosition.speed,
heading: initialPosition.heading,
altitude: initialPosition.altitude,
tripStatus: "empty_start", // 공차 시작
departure,
arrival,
departureName,
destinationName,
companyCode,
});
console.log("📍 공차 시작 위치 기록 완료:", tripId);
} catch (err) {
console.warn("⚠️ 공차 시작 위치 기록 실패 (테이블 없을 수 있음):", err);
}
// 추적 간격 (기본 10초)
const trackingInterval = config.emptyVehicleTrackingInterval || 10000;
// watchPosition으로 연속 추적
this.emptyVehicleWatchId = navigator.geolocation.watchPosition(
async (position) => {
const { latitude, longitude, accuracy, speed, heading, altitude } = position.coords;
try {
await apiClient.post("/dynamic-form/location-history", {
tripId: this.emptyVehicleTripId,
userId,
latitude,
longitude,
accuracy,
speed,
heading,
altitude,
tripStatus: "empty_tracking", // 공차 추적 중
departure,
arrival,
departureName,
destinationName,
companyCode,
});
console.log("📍 공차 위치 기록:", { latitude: latitude.toFixed(6), longitude: longitude.toFixed(6) });
} catch (err) {
console.warn("⚠️ 공차 위치 기록 실패:", err);
}
},
(error) => {
console.error("❌ 공차 위치 추적 오류:", error.message);
},
{
enableHighAccuracy: true,
timeout: trackingInterval,
maximumAge: 0,
}
);
console.log("🚗 공차 위치 추적 시작:", { tripId, watchId: this.emptyVehicleWatchId });
} catch (error) {
console.error("❌ 공차 위치 추적 시작 실패:", error);
}
}
/**
* 공차 위치 추적 중지 (운행 전환 시 호출)
*/
public static stopEmptyVehicleTracking(): void {
if (this.emptyVehicleWatchId !== null) {
navigator.geolocation.clearWatch(this.emptyVehicleWatchId);
console.log("🛑 공차 위치 추적 중지:", { tripId: this.emptyVehicleTripId, watchId: this.emptyVehicleWatchId });
this.emptyVehicleWatchId = null;
this.emptyVehicleTripId = null;
}
}
/**
* 현재 공차 추적 Trip ID 반환
*/
public static getEmptyVehicleTripId(): string | null {
return this.emptyVehicleTripId;
}
/**
* 필드 값 교환 액션 처리 (예: 출발지 ↔ 도착지)
*/
private static async handleSwapFields(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 필드 값 교환 액션 실행:", { config, context });
const { formData, onFormDataChange } = context;
// 교환할 필드 확인
const fieldA = config.swapFieldA;
const fieldB = config.swapFieldB;
if (!fieldA || !fieldB) {
toast.error("교환할 필드가 설정되지 않았습니다.");
return false;
}
// 현재 값 가져오기
const valueA = formData?.[fieldA];
const valueB = formData?.[fieldB];
console.log("🔄 교환 전:", { [fieldA]: valueA, [fieldB]: valueB });
// 값 교환
if (onFormDataChange) {
onFormDataChange(fieldA, valueB);
onFormDataChange(fieldB, valueA);
}
// 관련 필드도 함께 교환 (예: 위도/경도)
if (config.swapRelatedFields && config.swapRelatedFields.length > 0) {
for (const related of config.swapRelatedFields) {
const relatedValueA = formData?.[related.fieldA];
const relatedValueB = formData?.[related.fieldB];
if (onFormDataChange) {
onFormDataChange(related.fieldA, relatedValueB);
onFormDataChange(related.fieldB, relatedValueA);
}
}
}
console.log("🔄 교환 후:", { [fieldA]: valueB, [fieldB]: valueA });
toast.success(config.successMessage || "값이 교환되었습니다.");
return true;
} catch (error) {
console.error("❌ 필드 값 교환 오류:", error);
toast.error(config.errorMessage || "값 교환 중 오류가 발생했습니다.");
return false;
}
}
/**
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
* 🆕 위치정보 수집 기능 추가
* 🆕 연속 위치 추적 기능 추가
*/
/**
* 운행알림 및 종료 액션 처리
* - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료)
*/
private static async handleOperationControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
try {
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
if (this.emptyVehicleWatchId !== null) {
this.stopEmptyVehicleTracking();
console.log("🛑 공차 추적 종료 후 운행 시작");
}
// 🆕 연속 위치 추적 모드 처리
if (config.updateWithTracking) {
const trackingConfig: ButtonActionConfig = {
...config,
trackingInterval: config.updateTrackingInterval || config.trackingInterval || 10000,
trackingStatusField: config.updateTargetField,
trackingStatusTableName: config.updateTableName || context.tableName,
trackingStatusKeyField: config.updateKeyField,
trackingStatusKeySourceField: config.updateKeySourceField,
};
if (config.updateTrackingMode === "start") {
trackingConfig.trackingStatusOnStart = config.updateTargetValue as string;
return await this.handleTrackingStart(trackingConfig, context);
} else if (config.updateTrackingMode === "stop") {
trackingConfig.trackingStatusOnStop = config.updateTargetValue as string;
return await this.handleTrackingStop(trackingConfig, context);
}
}
const { formData, tableName, onFormDataChange, onSave } = context;
// 변경할 필드 확인
const targetField = config.updateTargetField;
const targetValue = config.updateTargetValue;
const multipleFields = config.updateMultipleFields || [];
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) {
toast.error("변경할 필드가 설정되지 않았습니다.");
return false;
}
// 확인 메시지 표시 (설정된 경우)
if (config.confirmMessage) {
const confirmed = window.confirm(config.confirmMessage);
if (!confirmed) {
console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)");
return false;
}
}
// 변경할 필드 목록 구성
const updates: Record<string, any> = {};
// 단일 필드 변경
if (targetField && targetValue !== undefined) {
updates[targetField] = targetValue;
}
// 다중 필드 변경
multipleFields.forEach(({ field, value }) => {
updates[field] = value;
});
// 🆕 위치정보 수집 (updateWithGeolocation이 true인 경우)
if (config.updateWithGeolocation) {
const latField = config.updateGeolocationLatField;
const lngField = config.updateGeolocationLngField;
if (!latField || !lngField) {
toast.error("위도/경도 저장 필드가 설정되지 않았습니다.");
return false;
}
// 브라우저 Geolocation API 지원 확인
if (!navigator.geolocation) {
toast.error("이 브라우저는 위치정보를 지원하지 않습니다.");
return false;
}
// 로딩 토스트 표시
const loadingToastId = toast.loading("위치 정보를 가져오는 중...");
try {
// 위치 정보 가져오기
const position = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0,
});
});
toast.dismiss(loadingToastId);
const { latitude, longitude, accuracy } = position.coords;
const timestamp = new Date(position.timestamp);
console.log("📍 위치정보 획득:", { latitude, longitude, accuracy });
// 위치정보를 updates에 추가
updates[latField] = latitude;
updates[lngField] = longitude;
if (config.updateGeolocationAccuracyField && accuracy !== null) {
updates[config.updateGeolocationAccuracyField] = accuracy;
}
if (config.updateGeolocationTimestampField) {
updates[config.updateGeolocationTimestampField] = timestamp.toISOString();
}
} catch (geoError: any) {
toast.dismiss(loadingToastId);
// GeolocationPositionError 처리
if (geoError.code === 1) {
toast.error("위치 정보 접근이 거부되었습니다.");
} else if (geoError.code === 2) {
toast.error("위치 정보를 사용할 수 없습니다.");
} else if (geoError.code === 3) {
toast.error("위치 정보 요청 시간이 초과되었습니다.");
} else {
toast.error("위치 정보를 가져오는 중 오류가 발생했습니다.");
}
return false;
}
}
console.log("🔄 변경할 필드들:", updates);
// formData 업데이트
if (onFormDataChange) {
Object.entries(updates).forEach(([field, value]) => {
onFormDataChange(field, value);
});
}
// 자동 저장 (기본값: true)
const autoSave = config.updateAutoSave !== false;
if (autoSave) {
// 🆕 키 필드 설정이 있는 경우 (특수 키워드 지원) - 직접 DB UPDATE
const keyField = config.updateKeyField;
const keySourceField = config.updateKeySourceField;
const targetTableName = config.updateTableName || tableName;
if (keyField && keySourceField) {
// 특수 키워드 변환 (예: __userId__ → 실제 사용자 ID)
const keyValue = resolveSpecialKeyword(keySourceField, context);
console.log("🔄 필드 값 변경 - 키 필드 사용:", {
targetTable: targetTableName,
keyField,
keySourceField,
keyValue,
updates,
});
if (!keyValue) {
console.warn("⚠️ 키 값이 없어서 업데이트를 건너뜁니다:", { keySourceField, keyValue });
toast.error("레코드를 식별할 키 값이 없습니다.");
return false;
}
try {
// 각 필드에 대해 개별 UPDATE 호출
const { apiClient } = await import("@/lib/api/client");
for (const [field, value] of Object.entries(updates)) {
console.log(`🔄 DB UPDATE: ${targetTableName}.${field} = ${value} WHERE ${keyField} = ${keyValue}`);
const response = await apiClient.put(`/dynamic-form/update-field`, {
tableName: targetTableName,
keyField: keyField,
keyValue: keyValue,
updateField: field,
updateValue: value,
});
if (!response.data?.success) {
console.error(`${field} 업데이트 실패:`, response.data);
toast.error(`${field} 업데이트에 실패했습니다.`);
return false;
}
}
console.log("✅ 모든 필드 업데이트 성공");
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(new CustomEvent("refreshTableData", {
detail: { tableName: targetTableName }
}));
return true;
} catch (apiError) {
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
return false;
}
}
// onSave 콜백이 있으면 사용
if (onSave) {
console.log("🔄 필드 값 변경 후 자동 저장 (onSave 콜백)");
try {
await onSave();
toast.success(config.successMessage || "상태가 변경되었습니다.");
return true;
} catch (saveError) {
console.error("❌ 필드 값 변경 저장 실패:", saveError);
toast.error(config.errorMessage || "상태 변경 저장에 실패했습니다.");
return false;
}
}
// API를 통한 직접 저장 (기존 방식: formData에 PK가 있는 경우)
if (tableName && formData) {
console.log("🔄 필드 값 변경 후 자동 저장 (API 직접 호출)");
try {
// PK 필드 찾기 (id 또는 테이블명_id)
const pkField = formData.id !== undefined ? "id" : `${tableName}_id`;
const pkValue = formData[pkField] || formData.id;
if (!pkValue) {
toast.error("레코드 ID를 찾을 수 없습니다. 키 필드를 설정해주세요.");
return false;
}
// 업데이트할 데이터 구성 (변경할 필드들만)
const updateData = {
...updates,
[pkField]: pkValue, // PK 포함
};
const response = await DynamicFormApi.updateData(tableName, updateData);
if (response.success) {
toast.success(config.successMessage || "상태가 변경되었습니다.");
// 테이블 새로고침 이벤트 발생
window.dispatchEvent(
new CustomEvent("refreshTableData", {
detail: { tableName },
}),
);
return true;
} else {
toast.error(response.message || config.errorMessage || "상태 변경에 실패했습니다.");
return false;
}
} catch (apiError) {
console.error("❌ 필드 값 변경 API 호출 실패:", apiError);
toast.error(config.errorMessage || "상태 변경 중 오류가 발생했습니다.");
return false;
}
}
}
// 자동 저장이 비활성화된 경우 폼 데이터만 변경
toast.success(config.successMessage || "필드 값이 변경되었습니다. 저장 버튼을 눌러 저장하세요.");
return true;
} catch (error) {
console.error("❌ 필드 값 변경 실패:", error);
toast.error(config.errorMessage || "필드 값 변경 중 오류가 발생했습니다.");
return false;
}
}
/**
* 폼 데이터 유효성 검사
*/
private static validateFormData(formData: Record<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: "편집되었습니다.",
},
copy: {
type: "copy",
confirmMessage: "복사하시겠습니까?",
successMessage: "복사되었습니다.",
errorMessage: "복사 중 오류가 발생했습니다.",
},
control: {
type: "control",
},
view_table_history: {
type: "view_table_history",
historyRecordIdField: "id",
historyRecordIdSource: "selected_row",
},
excel_download: {
type: "excel_download",
excelIncludeHeaders: true,
successMessage: "엑셀 파일이 다운로드되었습니다.",
errorMessage: "엑셀 다운로드 중 오류가 발생했습니다.",
},
excel_upload: {
type: "excel_upload",
excelUploadMode: "insert",
confirmMessage: "엑셀 파일을 업로드하시겠습니까?",
successMessage: "엑셀 파일이 업로드되었습니다.",
errorMessage: "엑셀 업로드 중 오류가 발생했습니다.",
},
barcode_scan: {
type: "barcode_scan",
barcodeFormat: "all",
barcodeAutoSubmit: false,
},
code_merge: {
type: "code_merge",
mergeShowPreview: true,
confirmMessage: "선택한 두 항목을 병합하시겠습니까?",
successMessage: "코드 병합이 완료되었습니다.",
errorMessage: "코드 병합 중 오류가 발생했습니다.",
},
transferData: {
type: "transferData",
successMessage: "데이터가 전달되었습니다.",
errorMessage: "데이터 전달 중 오류가 발생했습니다.",
},
empty_vehicle: {
type: "empty_vehicle",
geolocationHighAccuracy: true,
geolocationTimeout: 10000,
geolocationMaxAge: 0,
geolocationLatField: "latitude",
geolocationLngField: "longitude",
geolocationAutoSave: true,
geolocationKeyField: "user_id",
geolocationKeySourceField: "__userId__",
geolocationExtraField: "status",
geolocationExtraValue: "inactive",
successMessage: "공차등록이 완료되었습니다.",
errorMessage: "공차등록 중 오류가 발생했습니다.",
},
operation_control: {
type: "operation_control",
updateAutoSave: true,
updateWithGeolocation: true,
updateGeolocationLatField: "latitude",
updateGeolocationLngField: "longitude",
updateKeyField: "user_id",
updateKeySourceField: "__userId__",
confirmMessage: "운행 상태를 변경하시겠습니까?",
successMessage: "운행 상태가 변경되었습니다.",
errorMessage: "운행 상태 변경 중 오류가 발생했습니다.",
},
swap_fields: {
type: "swap_fields",
successMessage: "필드 값이 교환되었습니다.",
errorMessage: "필드 값 교환 중 오류가 발생했습니다.",
},
};