6973 lines
278 KiB
TypeScript
6973 lines
278 KiB
TypeScript
"use client";
|
||
|
||
import React from "react";
|
||
import { toast } from "sonner";
|
||
import { screenApi } from "@/lib/api/screen";
|
||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||
import { ImprovedButtonActionExecutor } from "@/lib/utils/improvedButtonActionExecutor";
|
||
import { apiClient } from "@/lib/api/client";
|
||
import type { ExtendedControlContext } from "@/types/control-management";
|
||
|
||
/**
|
||
* 버튼 액션 타입 정의
|
||
*/
|
||
export type ButtonActionType =
|
||
| "save" // 저장
|
||
| "delete" // 삭제
|
||
| "edit" // 편집
|
||
| "copy" // 복사 (품목코드 초기화)
|
||
| "navigate" // 페이지 이동
|
||
| "openRelatedModal" // 연관 데이터 버튼의 선택 데이터로 모달 열기
|
||
| "openModalWithData" // 데이터를 전달하면서 모달 열기
|
||
| "modal" // 모달 열기
|
||
| "control" // 제어 흐름
|
||
| "view_table_history" // 테이블 이력 보기
|
||
| "excel_download" // 엑셀 다운로드
|
||
| "excel_upload" // 엑셀 업로드
|
||
| "barcode_scan" // 바코드 스캔
|
||
| "code_merge" // 코드 병합
|
||
// | "empty_vehicle" // 공차등록 (위치 수집 + 상태 변경) - 운행알림으로 통합
|
||
| "operation_control" // 운행알림 및 종료 (위치 수집 + 상태 변경 + 연속 추적)
|
||
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
|
||
| "transferData" // 데이터 전달 (컴포넌트 간 or 화면 간)
|
||
| "quickInsert"; // 즉시 저장 (선택한 데이터를 특정 테이블에 즉시 INSERT)
|
||
|
||
/**
|
||
* 버튼 액션 설정
|
||
*/
|
||
export interface ButtonActionConfig {
|
||
type: ButtonActionType;
|
||
|
||
// 저장/제출 관련
|
||
saveEndpoint?: string;
|
||
validateForm?: boolean;
|
||
|
||
// 네비게이션 관련
|
||
targetUrl?: string;
|
||
targetScreenId?: number;
|
||
|
||
// 모달/팝업 관련
|
||
modalTitle?: string;
|
||
modalTitleBlocks?: Array<{
|
||
// 🆕 블록 기반 제목 (우선순위 높음)
|
||
id: string;
|
||
type: "text" | "field";
|
||
value: string; // type=text: 텍스트 내용, type=field: 컬럼명
|
||
tableName?: string; // type=field일 때 테이블명
|
||
label?: string; // type=field일 때 표시용 라벨
|
||
}>;
|
||
modalDescription?: string;
|
||
modalSize?: "sm" | "md" | "lg" | "xl";
|
||
popupWidth?: number;
|
||
popupHeight?: number;
|
||
dataSourceId?: string; // 🆕 modalDataStore에서 데이터를 가져올 ID (openModalWithData용)
|
||
fieldMappings?: Array<{ sourceField: string; targetField: string }>; // 🆕 필드 매핑 (openModalWithData용)
|
||
|
||
// 확인 메시지
|
||
confirmMessage?: string;
|
||
successMessage?: string;
|
||
errorMessage?: string;
|
||
|
||
// 제어관리 관련
|
||
enableDataflowControl?: boolean;
|
||
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
|
||
dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍
|
||
|
||
// 테이블 이력 보기 관련
|
||
historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정)
|
||
historyRecordIdField?: string; // PK 필드명 (기본: "id")
|
||
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
|
||
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
|
||
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
||
|
||
// 엑셀 다운로드 관련
|
||
excelFileName?: string; // 다운로드할 파일명 (기본: 테이블명_날짜.xlsx)
|
||
excelSheetName?: string; // 시트명 (기본: "Sheet1")
|
||
excelIncludeHeaders?: boolean; // 헤더 포함 여부 (기본: true)
|
||
|
||
// 엑셀 업로드 관련
|
||
excelUploadMode?: "insert" | "update" | "upsert"; // 업로드 모드
|
||
excelKeyColumn?: string; // 업데이트/Upsert 시 키 컬럼
|
||
|
||
// 바코드 스캔 관련
|
||
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; // 최대 선택 개수
|
||
};
|
||
};
|
||
|
||
// 연관 데이터 버튼 모달 열기 관련
|
||
relatedModalConfig?: {
|
||
targetScreenId: number; // 열릴 모달 화면 ID
|
||
componentId?: string; // 특정 RelatedDataButtons 컴포넌트 지정 (선택사항)
|
||
};
|
||
|
||
// 즉시 저장 (Quick Insert) 관련
|
||
quickInsertConfig?: {
|
||
targetTable: string; // 저장할 테이블명
|
||
columnMappings: Array<{
|
||
targetColumn: string; // 대상 테이블의 컬럼명
|
||
sourceType: "component" | "leftPanel" | "fixed" | "currentUser"; // 값 소스 타입
|
||
sourceComponentId?: string; // 컴포넌트에서 값을 가져올 경우 컴포넌트 ID
|
||
sourceColumnName?: string; // 컴포넌트의 columnName (formData 접근용)
|
||
sourceColumn?: string; // 좌측 패널 또는 컴포넌트의 특정 컬럼
|
||
fixedValue?: any; // 고정값
|
||
userField?: "userId" | "userName" | "companyCode"; // currentUser 타입일 때 사용할 필드
|
||
}>;
|
||
duplicateCheck?: {
|
||
enabled: boolean; // 중복 체크 활성화 여부
|
||
columns?: string[]; // 중복 체크할 컬럼들
|
||
errorMessage?: string; // 중복 시 에러 메시지
|
||
};
|
||
afterInsert?: {
|
||
refreshData?: boolean; // 저장 후 데이터 새로고침 (테이블리스트, 카드 디스플레이)
|
||
clearComponents?: boolean; // 저장 후 컴포넌트 값 초기화
|
||
showSuccessMessage?: boolean; // 성공 메시지 표시 여부 (기본: true)
|
||
successMessage?: string; // 성공 메시지
|
||
};
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 버튼 액션 실행 컨텍스트
|
||
*/
|
||
export interface ButtonActionContext {
|
||
formData: Record<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>;
|
||
|
||
// 🆕 분할 패널 컨텍스트 (quickInsert 등에서 좌측 패널 데이터 접근용)
|
||
splitPanelContext?: {
|
||
selectedLeftData?: Record<string, any>;
|
||
refreshRightPanel?: () => void;
|
||
};
|
||
|
||
// 🆕 저장된 데이터 (저장 후 제어 실행 시 플로우에 전달)
|
||
savedData?: any;
|
||
}
|
||
|
||
/**
|
||
* 🆕 특수 키워드를 실제 값으로 변환하는 헬퍼 함수
|
||
* 지원하는 키워드:
|
||
* - __userId__ : 로그인한 사용자 ID
|
||
* - __userName__ : 로그인한 사용자 이름
|
||
* - __companyCode__ : 로그인한 사용자의 회사 코드
|
||
* - __screenId__ : 현재 화면 ID
|
||
* - __tableName__ : 현재 테이블명
|
||
*/
|
||
export function resolveSpecialKeyword(sourceField: string | undefined, context: ButtonActionContext): any {
|
||
if (!sourceField) return undefined;
|
||
|
||
// 특수 키워드 처리
|
||
switch (sourceField) {
|
||
case "__userId__":
|
||
console.log("🔑 특수 키워드 변환: __userId__ →", context.userId);
|
||
return context.userId;
|
||
case "__userName__":
|
||
console.log("🔑 특수 키워드 변환: __userName__ →", context.userName);
|
||
return context.userName;
|
||
case "__companyCode__":
|
||
console.log("🔑 특수 키워드 변환: __companyCode__ →", context.companyCode);
|
||
return context.companyCode;
|
||
case "__screenId__":
|
||
console.log("🔑 특수 키워드 변환: __screenId__ →", context.screenId);
|
||
return context.screenId;
|
||
case "__tableName__":
|
||
console.log("🔑 특수 키워드 변환: __tableName__ →", context.tableName);
|
||
return context.tableName;
|
||
default:
|
||
// 일반 폼 데이터에서 가져오기
|
||
return context.formData?.[sourceField];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 버튼 액션 실행기
|
||
*/
|
||
export class ButtonActionExecutor {
|
||
/**
|
||
* 액션 실행
|
||
*/
|
||
static async executeAction(config: ButtonActionConfig, context: ButtonActionContext): Promise<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 "openRelatedModal":
|
||
return await this.handleOpenRelatedModal(config, context);
|
||
|
||
case "modal":
|
||
return await this.handleModal(config, context);
|
||
|
||
case "edit":
|
||
return await this.handleEdit(config, context);
|
||
|
||
case "control":
|
||
return this.handleControl(config, context);
|
||
|
||
case "view_table_history":
|
||
return this.handleViewTableHistory(config, context);
|
||
|
||
case "excel_download":
|
||
return await this.handleExcelDownload(config, context);
|
||
|
||
case "excel_upload":
|
||
return await this.handleExcelUpload(config, context);
|
||
|
||
case "barcode_scan":
|
||
return await this.handleBarcodeScan(config, context);
|
||
|
||
case "code_merge":
|
||
return await this.handleCodeMerge(config, context);
|
||
|
||
case "transferData":
|
||
return await this.handleTransferData(config, context);
|
||
|
||
// case "empty_vehicle":
|
||
// return await this.handleEmptyVehicle(config, context);
|
||
|
||
case "operation_control":
|
||
return await this.handleOperationControl(config, context);
|
||
|
||
case "swap_fields":
|
||
return await this.handleSwapFields(config, context);
|
||
|
||
case "quickInsert":
|
||
return await this.handleQuickInsert(config, context);
|
||
|
||
default:
|
||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error("버튼 액션 실행 오류:", error);
|
||
toast.error(config.errorMessage || "작업 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 필수 항목 검증
|
||
*/
|
||
private static validateRequiredFields(context: ButtonActionContext): { isValid: boolean; missingFields: string[] } {
|
||
const missingFields: string[] = [];
|
||
const { formData, allComponents } = context;
|
||
|
||
if (!allComponents || allComponents.length === 0) {
|
||
console.log("⚠️ [validateRequiredFields] allComponents 없음 - 검증 스킵");
|
||
return { isValid: true, missingFields: [] };
|
||
}
|
||
|
||
allComponents.forEach((component: any) => {
|
||
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
|
||
const isRequired =
|
||
component.required === true ||
|
||
component.style?.required === true ||
|
||
component.componentConfig?.required === true;
|
||
|
||
const columnName = component.columnName || component.style?.columnName;
|
||
const label = component.label || component.style?.label || columnName;
|
||
|
||
if (isRequired && columnName) {
|
||
const value = formData[columnName];
|
||
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
|
||
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
||
console.log("🔍 [validateRequiredFields] 필수 항목 누락:", {
|
||
columnName,
|
||
label,
|
||
value,
|
||
isRequired,
|
||
});
|
||
missingFields.push(label || columnName);
|
||
}
|
||
}
|
||
});
|
||
|
||
return {
|
||
isValid: missingFields.length === 0,
|
||
missingFields,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 저장 액션 처리 (INSERT/UPDATE 자동 판단 - DB 기반)
|
||
*/
|
||
private static saveCallCount = 0; // 🆕 호출 횟수 추적
|
||
private static saveLock: Map<string, number> = new Map(); // 🆕 중복 호출 방지 락
|
||
|
||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
this.saveCallCount++;
|
||
const callId = this.saveCallCount;
|
||
|
||
const { formData, originalData, tableName, screenId, onSave } = context;
|
||
|
||
// 🆕 중복 호출 방지: 같은 screenId + tableName + formData 조합으로 2초 내 재호출 시 무시
|
||
const formDataHash = JSON.stringify(Object.keys(formData).sort());
|
||
const lockKey = `${screenId}-${tableName}-${formDataHash}`;
|
||
const lastCallTime = this.saveLock.get(lockKey) || 0;
|
||
const now = Date.now();
|
||
const timeDiff = now - lastCallTime;
|
||
|
||
console.log(`🔒 [handleSave #${callId}] 락 체크:`, { lockKey: lockKey.slice(0, 50), timeDiff, threshold: 2000 });
|
||
|
||
if (timeDiff < 2000) {
|
||
console.log(`⏭️ [handleSave #${callId}] 중복 호출 무시 (2초 내 재호출):`, {
|
||
lockKey: lockKey.slice(0, 50),
|
||
timeDiff,
|
||
});
|
||
return true; // 중복 호출은 성공으로 처리
|
||
}
|
||
|
||
this.saveLock.set(lockKey, now);
|
||
|
||
console.log(`💾 [handleSave #${callId}] 저장 시작:`, {
|
||
callId,
|
||
formDataKeys: Object.keys(formData),
|
||
tableName,
|
||
screenId,
|
||
hasOnSave: !!onSave,
|
||
});
|
||
|
||
// ✅ 필수 항목 검증
|
||
console.log("🔍 [handleSave] 필수 항목 검증 시작:", {
|
||
hasAllComponents: !!context.allComponents,
|
||
allComponentsLength: context.allComponents?.length || 0,
|
||
});
|
||
const requiredValidation = this.validateRequiredFields(context);
|
||
if (!requiredValidation.isValid) {
|
||
console.log("❌ [handleSave] 필수 항목 누락:", requiredValidation.missingFields);
|
||
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
|
||
return false;
|
||
}
|
||
console.log("✅ [handleSave] 필수 항목 검증 통과");
|
||
|
||
// 🆕 EditModal 등에서 전달된 onSave 콜백이 있으면 우선 사용
|
||
if (onSave) {
|
||
console.log("✅ [handleSave] onSave 콜백 발견 - 콜백 실행");
|
||
try {
|
||
await onSave();
|
||
return true;
|
||
} catch (error) {
|
||
console.error("❌ [handleSave] onSave 콜백 실행 오류:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
console.log("⚠️ [handleSave] onSave 콜백 없음 - 기본 저장 로직 실행");
|
||
|
||
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||
// context.formData를 이벤트 detail에 포함하여 직접 수정 가능하게 함
|
||
// skipDefaultSave 플래그를 통해 기본 저장 로직을 건너뛸 수 있음
|
||
const beforeSaveEventDetail = {
|
||
formData: context.formData,
|
||
skipDefaultSave: false,
|
||
};
|
||
window.dispatchEvent(
|
||
new CustomEvent("beforeFormSave", {
|
||
detail: beforeSaveEventDetail,
|
||
}),
|
||
);
|
||
|
||
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||
|
||
// 🔧 skipDefaultSave 플래그 확인 - SelectedItemsDetailInput 등에서 자체 UPSERT 처리 시 기본 저장 건너뛰기
|
||
if (beforeSaveEventDetail.skipDefaultSave) {
|
||
console.log("🚫 [handleSave] skipDefaultSave=true - 기본 저장 로직 건너뛰기 (컴포넌트에서 자체 처리)");
|
||
return true;
|
||
}
|
||
|
||
console.log("📦 [handleSave] beforeFormSave 이벤트 후 formData:", context.formData);
|
||
|
||
// 🆕 렉 구조 컴포넌트 일괄 저장 감지
|
||
let rackStructureLocations: any[] | undefined;
|
||
let rackStructureFieldKey = "_rackStructureLocations";
|
||
let hasEmptyRackStructureField = false;
|
||
|
||
// formData에서 렉 구조 데이터 또는 빈 배열 찾기
|
||
for (const [key, value] of Object.entries(context.formData || {})) {
|
||
// 배열인 경우만 체크
|
||
if (Array.isArray(value)) {
|
||
if (value.length > 0 && value[0]) {
|
||
const firstItem = value[0];
|
||
const isNewFormat =
|
||
firstItem.location_code &&
|
||
firstItem.location_name &&
|
||
firstItem.row_num !== undefined &&
|
||
firstItem.level_num !== undefined;
|
||
const isOldFormat =
|
||
firstItem.locationCode &&
|
||
firstItem.locationName &&
|
||
firstItem.rowNum !== undefined &&
|
||
firstItem.levelNum !== undefined;
|
||
|
||
if (isNewFormat || isOldFormat) {
|
||
console.log("🏗️ [handleSave] 렉 구조 데이터 감지 - 필드:", key);
|
||
rackStructureLocations = value;
|
||
rackStructureFieldKey = key;
|
||
break;
|
||
}
|
||
} else if (value.length === 0 && key.startsWith("comp_")) {
|
||
// comp_로 시작하는 빈 배열은 렉 구조 컴포넌트일 가능성 있음
|
||
// allComponents에서 확인
|
||
const rackStructureComponentInLayout = context.allComponents?.find(
|
||
(comp: any) =>
|
||
comp.type === "component" && comp.componentId === "rack-structure" && comp.columnName === key,
|
||
);
|
||
if (rackStructureComponentInLayout) {
|
||
console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 (미리보기 없음) - 필드:", key);
|
||
hasEmptyRackStructureField = true;
|
||
rackStructureFieldKey = key;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 렉 구조 컴포넌트가 있지만 미리보기 데이터가 없는 경우
|
||
if (hasEmptyRackStructureField && (!rackStructureLocations || rackStructureLocations.length === 0)) {
|
||
alert("미리보기를 먼저 생성해주세요.\n\n렉 구조 조건을 설정한 후 '미리보기 생성' 버튼을 클릭하세요.");
|
||
return false;
|
||
}
|
||
|
||
// 🆕 렉 구조 등록 화면 감지 (warehouse_location 테이블 + floor/zone 필드 있음 + 렉 구조 데이터 없음)
|
||
// 이 경우 일반 저장을 차단하고 미리보기 생성을 요구
|
||
const isRackStructureScreen =
|
||
context.tableName === "warehouse_location" &&
|
||
context.formData?.floor &&
|
||
context.formData?.zone &&
|
||
!rackStructureLocations;
|
||
|
||
if (isRackStructureScreen) {
|
||
console.log("🏗️ [handleSave] 렉 구조 등록 화면 감지 - 미리보기 데이터 없음");
|
||
alert(
|
||
"렉 구조 등록 화면입니다.\n\n" +
|
||
"미리보기를 먼저 생성해주세요.\n" +
|
||
"- 중복된 위치가 있으면 미리보기가 생성되지 않습니다.\n" +
|
||
"- 기존 데이터를 삭제하거나 다른 열/단을 선택해주세요.",
|
||
);
|
||
return false;
|
||
}
|
||
|
||
// 렉 구조 데이터가 있으면 일괄 저장
|
||
if (rackStructureLocations && rackStructureLocations.length > 0) {
|
||
console.log("🏗️ [handleSave] 렉 구조 컴포넌트 감지 - 일괄 저장 시작:", rackStructureLocations.length, "개");
|
||
return await this.handleRackStructureBatchSave(config, context, rackStructureLocations, rackStructureFieldKey);
|
||
}
|
||
|
||
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
|
||
console.log("🔍 [handleSave] formData 구조 확인:", {
|
||
isFormDataArray: Array.isArray(context.formData),
|
||
keys: Object.keys(context.formData),
|
||
values: Object.entries(context.formData).map(([key, value]) => ({
|
||
key,
|
||
isArray: Array.isArray(value),
|
||
length: Array.isArray(value) ? value.length : 0,
|
||
firstItem:
|
||
Array.isArray(value) && value.length > 0
|
||
? {
|
||
hasOriginalData: !!value[0]?.originalData,
|
||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||
keys: Object.keys(value[0] || {}),
|
||
}
|
||
: null,
|
||
})),
|
||
});
|
||
|
||
// 🔧 formData 자체가 배열인 경우 (ScreenModal의 그룹 레코드 수정)
|
||
if (Array.isArray(context.formData)) {
|
||
console.log(
|
||
"⚠️ [handleSave] formData가 배열입니다 - SelectedItemsDetailInput이 이미 처리했으므로 일반 저장 건너뜀",
|
||
);
|
||
console.log("⚠️ [handleSave] formData 배열:", context.formData);
|
||
// ✅ SelectedItemsDetailInput이 이미 UPSERT를 실행했으므로 일반 저장을 건너뜀
|
||
return true; // 성공으로 반환
|
||
}
|
||
|
||
const selectedItemsKeys = Object.keys(context.formData).filter((key) => {
|
||
const value = context.formData[key];
|
||
console.log(`🔍 [handleSave] 필터링 체크 - ${key}:`, {
|
||
isArray: Array.isArray(value),
|
||
length: Array.isArray(value) ? value.length : 0,
|
||
firstItem:
|
||
Array.isArray(value) && value.length > 0
|
||
? {
|
||
keys: Object.keys(value[0] || {}),
|
||
hasOriginalData: !!value[0]?.originalData,
|
||
hasFieldGroups: !!value[0]?.fieldGroups,
|
||
actualValue: value[0],
|
||
}
|
||
: null,
|
||
});
|
||
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
||
});
|
||
|
||
if (selectedItemsKeys.length > 0) {
|
||
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
||
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||
} else {
|
||
console.log("⚠️ [handleSave] SelectedItemsDetailInput 데이터 감지 실패 - 일반 저장 진행");
|
||
console.log("⚠️ [handleSave] formData 전체 내용:", context.formData);
|
||
}
|
||
|
||
// 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||
// 범용_폼_모달 내부에 _tableSection_ 데이터가 있는 경우 공통 필드 + 개별 품목 병합 저장
|
||
const universalFormModalResult = await this.handleUniversalFormModalTableSectionSave(config, context, formData);
|
||
if (universalFormModalResult.handled) {
|
||
console.log("✅ [handleSave] Universal Form Modal 테이블 섹션 저장 완료");
|
||
return universalFormModalResult.success;
|
||
}
|
||
|
||
// 폼 유효성 검사
|
||
if (config.validateForm) {
|
||
const validation = this.validateFormData(formData);
|
||
if (!validation.isValid) {
|
||
toast.error(`입력값을 확인해주세요: ${validation.errors.join(", ")}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
try {
|
||
// API 엔드포인트가 지정된 경우
|
||
if (config.saveEndpoint) {
|
||
const response = await fetch(config.saveEndpoint, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify(formData),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`저장 실패: ${response.statusText}`);
|
||
}
|
||
} else if (tableName && screenId) {
|
||
// DB에서 실제 기본키 조회하여 INSERT/UPDATE 자동 판단
|
||
const primaryKeyResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
||
|
||
if (!primaryKeyResult.success) {
|
||
throw new Error(primaryKeyResult.message || "기본키 조회에 실패했습니다.");
|
||
}
|
||
|
||
const primaryKeys = primaryKeyResult.data || [];
|
||
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
||
|
||
// 🔧 수정: originalData가 있고 실제 데이터가 있으면 UPDATE 모드로 처리
|
||
// originalData는 수정 버튼 클릭 시 editData로 전달되어 context.originalData로 설정됨
|
||
// 빈 객체 {}도 truthy이므로 Object.keys로 실제 데이터 유무 확인
|
||
const hasRealOriginalData = originalData && Object.keys(originalData).length > 0;
|
||
|
||
// 🆕 폴백 로직: originalData가 없어도 formData에 id가 있으면 UPDATE로 판단
|
||
// 조건부 컨테이너 등에서 originalData 전달이 누락되는 경우를 처리
|
||
const hasIdInFormData = formData.id !== undefined && formData.id !== null && formData.id !== "";
|
||
const isUpdate = (hasRealOriginalData || hasIdInFormData) && !!primaryKeyValue;
|
||
|
||
console.log("🔍 [handleSave] INSERT/UPDATE 판단:", {
|
||
hasOriginalData: !!originalData,
|
||
hasRealOriginalData,
|
||
hasIdInFormData,
|
||
originalDataKeys: originalData ? Object.keys(originalData) : [],
|
||
primaryKeyValue,
|
||
isUpdate,
|
||
primaryKeys,
|
||
});
|
||
|
||
let saveResult;
|
||
|
||
if (isUpdate) {
|
||
// UPDATE 처리 - 부분 업데이트 사용 (원본 데이터가 있는 경우)
|
||
console.log("🔄 UPDATE 모드로 저장:", {
|
||
primaryKeyValue,
|
||
hasOriginalData: !!originalData,
|
||
hasIdInFormData,
|
||
updateReason: hasRealOriginalData ? "originalData 존재" : "formData.id 존재 (폴백)",
|
||
});
|
||
|
||
if (hasRealOriginalData) {
|
||
// 부분 업데이트: 변경된 필드만 업데이트
|
||
console.log("📝 부분 업데이트 실행 (변경된 필드만)");
|
||
saveResult = await DynamicFormApi.updateFormDataPartial(primaryKeyValue, originalData, formData, tableName);
|
||
} else {
|
||
// 전체 업데이트 (originalData 없이 id로 UPDATE 판단된 경우)
|
||
console.log("📝 전체 업데이트 실행 (originalData 없음 - 폴백 모드)");
|
||
saveResult = await DynamicFormApi.updateFormData(primaryKeyValue, {
|
||
tableName,
|
||
data: formData,
|
||
});
|
||
}
|
||
} else {
|
||
// INSERT 처리
|
||
|
||
// 🆕 자동으로 작성자 정보 추가
|
||
if (!context.userId) {
|
||
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
|
||
}
|
||
|
||
const writerValue = context.userId;
|
||
const companyCodeValue = context.companyCode || "";
|
||
|
||
// console.log("👤 [buttonActions] 사용자 정보:", {
|
||
// userId: context.userId,
|
||
// userName: context.userName,
|
||
// companyCode: context.companyCode,
|
||
// formDataWriter: formData.writer,
|
||
// formDataCompanyCode: formData.company_code,
|
||
// defaultWriterValue: writerValue,
|
||
// companyCodeValue,
|
||
// });
|
||
|
||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||
console.log("🔍 채번 규칙 할당 체크 시작");
|
||
|
||
const fieldsWithNumbering: Record<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");
|
||
|
||
let hasAllocationFailure = false;
|
||
const failedFields: string[] = [];
|
||
|
||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||
try {
|
||
console.log(`🔄 ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
|
||
const allocateResult = await allocateNumberingCode(ruleId);
|
||
|
||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||
const newCode = allocateResult.data.generatedCode;
|
||
console.log(`✅ ${fieldName} 새 코드 할당: ${formData[fieldName]} → ${newCode}`);
|
||
formData[fieldName] = newCode;
|
||
} else {
|
||
console.warn(`⚠️ ${fieldName} 코드 할당 실패:`, allocateResult.error);
|
||
// 🆕 기존 값이 빈 문자열이면 실패로 표시
|
||
if (!formData[fieldName] || formData[fieldName] === "") {
|
||
hasAllocationFailure = true;
|
||
failedFields.push(fieldName);
|
||
}
|
||
}
|
||
} catch (allocateError) {
|
||
console.error(`❌ ${fieldName} 코드 할당 오류:`, allocateError);
|
||
// 🆕 기존 값이 빈 문자열이면 실패로 표시
|
||
if (!formData[fieldName] || formData[fieldName] === "") {
|
||
hasAllocationFailure = true;
|
||
failedFields.push(fieldName);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🆕 채번 규칙 할당 실패 시 저장 중단
|
||
if (hasAllocationFailure) {
|
||
const fieldNames = failedFields.join(", ");
|
||
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
|
||
console.error(`❌ 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
|
||
console.error("💡 해결 방법: 화면관리에서 해당 필드의 채번 규칙 설정을 확인하세요.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
console.log("✅ 채번 규칙 할당 완료");
|
||
console.log("📦 최종 formData:", JSON.stringify(formData, null, 2));
|
||
|
||
// 🆕 분할 패널 부모 데이터 병합 (좌측 화면에서 선택된 데이터)
|
||
// 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 병합해야 함
|
||
// 모든 필드를 병합하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
|
||
// 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
|
||
const rawSplitPanelData = context.splitPanelParentData || {};
|
||
|
||
// INSERT 모드에서는 연결에 필요한 필드만 추출
|
||
const cleanedSplitPanelData: Record<string, any> = {};
|
||
|
||
// 필수 연결 필드: company_code (멀티테넌시)
|
||
if (rawSplitPanelData.company_code) {
|
||
cleanedSplitPanelData.company_code = rawSplitPanelData.company_code;
|
||
}
|
||
|
||
// 연결 필드 패턴으로 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
|
||
const linkFieldPatterns = ["_code", "_id"];
|
||
const excludeFields = [
|
||
"id",
|
||
"company_code",
|
||
"created_date",
|
||
"updated_date",
|
||
"created_at",
|
||
"updated_at",
|
||
"writer",
|
||
"created_by",
|
||
"updated_by",
|
||
];
|
||
|
||
for (const [key, value] of Object.entries(rawSplitPanelData)) {
|
||
if (excludeFields.includes(key)) continue;
|
||
if (value === undefined || value === null) continue;
|
||
|
||
// 연결 필드 패턴 확인
|
||
const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
|
||
if (isLinkField) {
|
||
cleanedSplitPanelData[key] = value;
|
||
console.log(`🔗 [handleSave] INSERT 모드 - 연결 필드만 병합: ${key} = ${value}`);
|
||
}
|
||
}
|
||
|
||
if (Object.keys(rawSplitPanelData).length > 0) {
|
||
console.log("🧹 [handleSave] 원본 분할 패널 부모 데이터:", Object.keys(rawSplitPanelData));
|
||
console.log("🧹 [handleSave] 정리된 분할 패널 부모 데이터 (연결 필드만):", cleanedSplitPanelData);
|
||
}
|
||
|
||
const dataWithUserInfo = {
|
||
...cleanedSplitPanelData, // 정리된 분할 패널 부모 데이터 먼저 적용
|
||
...formData, // 폼 데이터가 우선 (덮어쓰기 가능)
|
||
writer: formData.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
||
created_by: writerValue, // created_by는 항상 로그인한 사람
|
||
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
||
company_code: formData.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
||
};
|
||
|
||
// 🔧 formData에서도 id 제거 (신규 INSERT이므로)
|
||
if ("id" in dataWithUserInfo && !formData.id) {
|
||
console.log("🗑️ [handleSave] INSERT 모드 - dataWithUserInfo에서 id 제거:", dataWithUserInfo.id);
|
||
delete dataWithUserInfo.id;
|
||
}
|
||
|
||
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
|
||
for (const key of Object.keys(dataWithUserInfo)) {
|
||
if (key.endsWith("_numberingRuleId")) {
|
||
delete dataWithUserInfo[key];
|
||
}
|
||
}
|
||
|
||
// 🆕 반복 필드 그룹에서 삭제된 항목 처리
|
||
// formData의 각 필드에서 _deletedItemIds가 있는지 확인
|
||
console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo));
|
||
|
||
for (const [key, value] of Object.entries(dataWithUserInfo)) {
|
||
console.log(`🔍 [handleSave] 필드 검사: ${key}`, {
|
||
type: typeof value,
|
||
isArray: Array.isArray(value),
|
||
isString: typeof value === "string",
|
||
valuePreview: typeof value === "string" ? value.substring(0, 100) : value,
|
||
});
|
||
|
||
let parsedValue = value;
|
||
|
||
// JSON 문자열인 경우 파싱 시도
|
||
if (typeof value === "string" && value.startsWith("[")) {
|
||
try {
|
||
parsedValue = JSON.parse(value);
|
||
console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue);
|
||
} catch (e) {
|
||
// 파싱 실패하면 원본 값 유지
|
||
}
|
||
}
|
||
|
||
if (Array.isArray(parsedValue) && parsedValue.length > 0) {
|
||
const firstItem = parsedValue[0];
|
||
const deletedItemIds = firstItem?._deletedItemIds;
|
||
const targetTable = firstItem?._targetTable;
|
||
|
||
console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, {
|
||
firstItemKeys: firstItem ? Object.keys(firstItem) : [],
|
||
deletedItemIds,
|
||
targetTable,
|
||
});
|
||
|
||
if (deletedItemIds && deletedItemIds.length > 0 && targetTable) {
|
||
console.log("🗑️ [handleSave] 삭제할 항목 발견:", {
|
||
fieldKey: key,
|
||
targetTable,
|
||
deletedItemIds,
|
||
});
|
||
|
||
// 삭제 API 호출
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🆕 RepeaterFieldGroup 데이터 저장 처리 (_targetTable이 있는 배열 데이터)
|
||
// formData에서 _targetTable 메타데이터가 포함된 배열 필드 찾기
|
||
console.log("🔎 [handleSave] formData 키 목록:", Object.keys(context.formData));
|
||
console.log("🔎 [handleSave] formData 전체:", context.formData);
|
||
|
||
for (const [fieldKey, fieldValue] of Object.entries(context.formData)) {
|
||
console.log(`🔎 [handleSave] 필드 검사: ${fieldKey}`, {
|
||
type: typeof fieldValue,
|
||
isArray: Array.isArray(fieldValue),
|
||
valuePreview: typeof fieldValue === "string" ? fieldValue.slice(0, 100) : fieldValue,
|
||
});
|
||
// JSON 문자열인 경우 파싱
|
||
let parsedData = fieldValue;
|
||
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||
try {
|
||
parsedData = JSON.parse(fieldValue);
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
// 배열이고 첫 번째 항목에 _targetTable이 있는 경우만 처리
|
||
if (!Array.isArray(parsedData) || parsedData.length === 0) continue;
|
||
|
||
const firstItem = parsedData[0];
|
||
const repeaterTargetTable = firstItem?._targetTable;
|
||
|
||
// _targetTable이 없거나, _repeatScreenModal_ 키면 스킵 (다른 로직에서 처리)
|
||
if (!repeaterTargetTable || fieldKey.startsWith("_repeatScreenModal_")) continue;
|
||
|
||
console.log(`📦 [handleSave] RepeaterFieldGroup 데이터 저장: ${fieldKey} → ${repeaterTargetTable}`, {
|
||
itemCount: parsedData.length,
|
||
});
|
||
|
||
// 🆕 범용 폼 모달의 공통 필드 추출 (order_no, manager_id 등)
|
||
// "범용_폼_모달" 키에서 공통 필드를 가져옴
|
||
const universalFormData = context.formData["범용_폼_모달"] as Record<string, unknown> | undefined;
|
||
const commonFields: Record<string, unknown> = {};
|
||
if (universalFormData && typeof universalFormData === "object") {
|
||
// 공통 필드 복사 (내부 메타 필드 제외)
|
||
for (const [key, value] of Object.entries(universalFormData)) {
|
||
if (!key.startsWith("_") && !key.endsWith("_numberingRuleId") && value !== undefined && value !== "") {
|
||
commonFields[key] = value;
|
||
}
|
||
}
|
||
console.log("📋 [handleSave] 범용 폼 모달 공통 필드:", commonFields);
|
||
}
|
||
|
||
// 🆕 루트 레벨 formData에서 RepeaterFieldGroup에 전달할 공통 필드 추출
|
||
// 규칙 기반 필터링: 하드코딩 대신 패턴으로 제외할 필드를 정의
|
||
for (const [fieldName, value] of Object.entries(context.formData)) {
|
||
// 제외 규칙 1: comp_로 시작하는 필드 (하위 항목 배열)
|
||
if (fieldName.startsWith("comp_")) continue;
|
||
// 제외 규칙 2: _numberingRuleId로 끝나는 필드 (채번 규칙 메타 정보)
|
||
if (fieldName.endsWith("_numberingRuleId")) continue;
|
||
// 제외 규칙 3: _로 시작하는 필드 (내부 메타 필드)
|
||
if (fieldName.startsWith("_")) continue;
|
||
// 제외 규칙 4: 배열 타입 (하위 항목 데이터)
|
||
if (Array.isArray(value)) continue;
|
||
// 제외 규칙 5: 객체 타입 (복잡한 구조 데이터) - null 제외
|
||
if (value !== null && typeof value === "object") continue;
|
||
// 제외 규칙 6: 빈 값
|
||
if (value === undefined || value === "" || value === null) continue;
|
||
// 제외 규칙 7: 이미 commonFields에 있는 필드 (범용 폼 모달에서 가져온 필드)
|
||
if (fieldName in commonFields) continue;
|
||
|
||
// 위 규칙에 해당하지 않는 단순 값(문자열, 숫자, 날짜 등)은 공통 필드로 전달
|
||
commonFields[fieldName] = value;
|
||
}
|
||
console.log("📋 [handleSave] 최종 공통 필드 (규칙 기반 자동 추출):", commonFields);
|
||
|
||
for (const item of parsedData) {
|
||
// 메타 필드 제거 (eslint 경고 무시 - 의도적으로 분리)
|
||
|
||
const {
|
||
_targetTable: _,
|
||
_isNewItem,
|
||
_existingRecord: __,
|
||
_originalItemIds: ___,
|
||
_deletedItemIds: ____,
|
||
...dataToSave
|
||
} = item;
|
||
|
||
// 🆕 빈 id 필드 제거 (새 항목인 경우)
|
||
if (!dataToSave.id || dataToSave.id === "" || dataToSave.id === null) {
|
||
delete dataToSave.id;
|
||
}
|
||
|
||
// 🆕 공통 필드 병합 + 사용자 정보 추가
|
||
// 개별 항목 데이터를 먼저 넣고, 공통 필드로 덮어씀 (공통 필드가 우선)
|
||
// 이유: 사용자가 공통 필드(출고상태 등)를 변경하면 모든 항목에 적용되어야 함
|
||
const dataWithMeta: Record<string, unknown> = {
|
||
...dataToSave, // RepeaterFieldGroup의 개별 항목 데이터
|
||
...commonFields, // 범용 폼 모달의 공통 필드 (outbound_status 등) - 공통 필드가 우선!
|
||
created_by: context.userId,
|
||
updated_by: context.userId,
|
||
company_code: context.companyCode,
|
||
};
|
||
|
||
try {
|
||
// 🆕 새 항목 판단: _isNewItem 플래그 또는 id가 없거나 빈 문자열인 경우
|
||
const isNewRecord = _isNewItem || !item.id || item.id === "" || item.id === undefined;
|
||
|
||
if (isNewRecord) {
|
||
// INSERT (새 항목)
|
||
// id 필드 완전히 제거 (자동 생성되도록)
|
||
delete dataWithMeta.id;
|
||
// 빈 문자열 id도 제거
|
||
if ("id" in dataWithMeta && (dataWithMeta.id === "" || dataWithMeta.id === null)) {
|
||
delete dataWithMeta.id;
|
||
}
|
||
console.log(`📝 [handleSave] RepeaterFieldGroup INSERT (${repeaterTargetTable}):`, dataWithMeta);
|
||
const insertResult = await apiClient.post(
|
||
`/table-management/tables/${repeaterTargetTable}/add`,
|
||
dataWithMeta,
|
||
);
|
||
console.log("✅ [handleSave] RepeaterFieldGroup INSERT 완료:", insertResult.data);
|
||
// 무시된 컬럼이 있으면 경고 출력
|
||
if (insertResult.data?.data?.skippedColumns?.length > 0) {
|
||
console.warn(
|
||
`⚠️ [${repeaterTargetTable}] 테이블에 존재하지 않는 컬럼이 무시됨:`,
|
||
insertResult.data.data.skippedColumns,
|
||
);
|
||
}
|
||
} else if (item.id) {
|
||
// UPDATE (기존 항목)
|
||
const originalData = { id: item.id };
|
||
const updatedData = { ...dataWithMeta, id: item.id };
|
||
console.log("📝 [handleSave] RepeaterFieldGroup UPDATE:", {
|
||
id: item.id,
|
||
table: repeaterTargetTable,
|
||
});
|
||
const updateResult = await apiClient.put(`/table-management/tables/${repeaterTargetTable}/edit`, {
|
||
originalData,
|
||
updatedData,
|
||
});
|
||
console.log("✅ [handleSave] RepeaterFieldGroup UPDATE 완료:", updateResult.data);
|
||
}
|
||
} catch (err) {
|
||
const error = err as { response?: { data?: unknown; status?: number }; message?: string };
|
||
console.error(
|
||
`❌ [handleSave] RepeaterFieldGroup 저장 실패 (${repeaterTargetTable}):`,
|
||
{
|
||
status: error.response?.status,
|
||
data: error.response?.data,
|
||
message: error.message,
|
||
fullError: JSON.stringify(error.response?.data, null, 2),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🆕 v3.9: RepeatScreenModal의 외부 테이블 데이터 저장 처리
|
||
const repeatScreenModalKeys = Object.keys(context.formData).filter(
|
||
(key) => key.startsWith("_repeatScreenModal_") && key !== "_repeatScreenModal_aggregations",
|
||
);
|
||
|
||
// RepeatScreenModal 데이터가 있으면 해당 테이블에 대한 메인 저장은 건너뜀
|
||
const repeatScreenModalTables = repeatScreenModalKeys.map((key) => key.replace("_repeatScreenModal_", ""));
|
||
|
||
// 🆕 RepeaterFieldGroup 테이블 목록 수집 (메인 저장 건너뛰기 판단용)
|
||
const repeaterFieldGroupTables: string[] = [];
|
||
for (const [, fieldValue] of Object.entries(context.formData)) {
|
||
let parsedData = fieldValue;
|
||
if (typeof fieldValue === "string" && fieldValue.startsWith("[")) {
|
||
try {
|
||
parsedData = JSON.parse(fieldValue);
|
||
} catch {
|
||
continue;
|
||
}
|
||
}
|
||
if (Array.isArray(parsedData) && parsedData.length > 0 && parsedData[0]?._targetTable) {
|
||
repeaterFieldGroupTables.push(parsedData[0]._targetTable);
|
||
}
|
||
}
|
||
|
||
// 메인 저장 건너뛰기 조건: RepeatScreenModal 또는 RepeaterFieldGroup에서 같은 테이블 처리
|
||
const shouldSkipMainSave =
|
||
repeatScreenModalTables.includes(tableName) || repeaterFieldGroupTables.includes(tableName);
|
||
|
||
if (shouldSkipMainSave) {
|
||
console.log(
|
||
`⏭️ [handleSave] ${tableName} 메인 저장 건너뜀 (RepeaterFieldGroup/RepeatScreenModal에서 처리)`,
|
||
{
|
||
repeatScreenModalTables,
|
||
repeaterFieldGroupTables,
|
||
},
|
||
);
|
||
saveResult = { success: true, message: "RepeaterFieldGroup/RepeatScreenModal에서 처리" };
|
||
} else {
|
||
saveResult = await DynamicFormApi.saveFormData({
|
||
screenId,
|
||
tableName,
|
||
data: dataWithUserInfo,
|
||
});
|
||
}
|
||
|
||
if (repeatScreenModalKeys.length > 0) {
|
||
console.log("📦 [handleSave] RepeatScreenModal 데이터 저장 시작:", repeatScreenModalKeys);
|
||
|
||
// 🆕 formData에서 채번 규칙으로 생성된 값들 추출 (예: shipment_plan_no)
|
||
const numberingFields: Record<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);
|
||
|
||
// 테이블 섹션 데이터 파싱 (comp_로 시작하는 필드에 JSON 배열이 있는 경우)
|
||
// 입고 화면 등에서 품목 목록이 comp_xxx 필드에 JSON 문자열로 저장됨
|
||
const formData: Record<string, any> = (saveResult.data || context.formData || {}) as Record<string, any>;
|
||
let parsedSectionData: any[] = [];
|
||
|
||
// comp_로 시작하는 필드에서 테이블 섹션 데이터 찾기
|
||
const compFieldKey = Object.keys(formData).find(key =>
|
||
key.startsWith("comp_") && typeof formData[key] === "string"
|
||
);
|
||
|
||
if (compFieldKey) {
|
||
try {
|
||
const sectionData = JSON.parse(formData[compFieldKey]);
|
||
if (Array.isArray(sectionData) && sectionData.length > 0) {
|
||
// 공통 필드와 섹션 데이터 병합
|
||
parsedSectionData = sectionData.map((item: any) => {
|
||
// 섹션 데이터에서 불필요한 내부 필드 제거
|
||
const { _isNewItem, _targetTable, _existingRecord, ...cleanItem } = item;
|
||
// 공통 필드(comp_ 필드 제외) + 섹션 아이템 병합
|
||
const commonFields: Record<string, any> = {};
|
||
Object.keys(formData).forEach(key => {
|
||
if (!key.startsWith("comp_") && !key.endsWith("_numberingRuleId")) {
|
||
commonFields[key] = formData[key];
|
||
}
|
||
});
|
||
return { ...commonFields, ...cleanItem };
|
||
});
|
||
console.log(`📦 [handleSave] 테이블 섹션 데이터 파싱 완료: ${parsedSectionData.length}건`, parsedSectionData[0]);
|
||
}
|
||
} catch (parseError) {
|
||
console.warn("⚠️ [handleSave] 테이블 섹션 데이터 파싱 실패:", parseError);
|
||
}
|
||
}
|
||
|
||
// 저장된 데이터를 context에 추가하여 플로우에 전달
|
||
const contextWithSavedData = {
|
||
...context,
|
||
savedData: formData,
|
||
// 파싱된 섹션 데이터가 있으면 selectedRowsData로 전달
|
||
selectedRowsData: parsedSectionData.length > 0 ? parsedSectionData : context.selectedRowsData,
|
||
};
|
||
await this.executeAfterSaveControl(config, contextWithSavedData);
|
||
}
|
||
} else {
|
||
throw new Error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||
}
|
||
|
||
// 테이블과 플로우 새로고침 (모달 닫기 전에 실행)
|
||
context.onRefresh?.();
|
||
context.onFlowRefresh?.();
|
||
|
||
// 저장 성공 후 이벤트 발생
|
||
window.dispatchEvent(new CustomEvent("closeEditModal")); // EditModal 닫기
|
||
window.dispatchEvent(new CustomEvent("saveSuccessInModal")); // ScreenModal 연속 등록 모드 처리
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error("저장 오류:", error);
|
||
throw error; // 에러를 다시 던져서 컴포넌트에서 처리하도록 함
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DB에서 조회한 실제 기본키로 formData에서 값 추출
|
||
* @param formData 폼 데이터
|
||
* @param primaryKeys DB에서 조회한 실제 기본키 컬럼명 배열
|
||
* @returns 기본키 값 (복합키의 경우 첫 번째 키 값)
|
||
*/
|
||
private static extractPrimaryKeyValueFromDB(formData: Record<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);
|
||
}
|
||
|
||
/**
|
||
* 🆕 렉 구조 컴포넌트 일괄 저장 처리
|
||
* 미리보기에서 생성된 위치 데이터를 일괄 INSERT
|
||
*/
|
||
private static async handleRackStructureBatchSave(
|
||
config: ButtonActionConfig,
|
||
context: ButtonActionContext,
|
||
locations: any[],
|
||
rackStructureFieldKey: string = "_rackStructureLocations",
|
||
): Promise<boolean> {
|
||
const { tableName, screenId, userId, companyCode } = context;
|
||
|
||
console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 일괄 저장 시작:", {
|
||
locationsCount: locations.length,
|
||
tableName,
|
||
screenId,
|
||
rackStructureFieldKey,
|
||
});
|
||
|
||
if (!tableName) {
|
||
throw new Error("테이블명이 지정되지 않았습니다.");
|
||
}
|
||
|
||
if (locations.length === 0) {
|
||
throw new Error("저장할 위치 데이터가 없습니다. 먼저 미리보기를 생성해주세요.");
|
||
}
|
||
|
||
console.log("🏗️ [handleRackStructureBatchSave] 렉 구조 데이터 예시:", locations[0]);
|
||
|
||
// 저장 전 중복 체크
|
||
const firstLocation = locations[0];
|
||
const warehouseCode = firstLocation.warehouse_code || firstLocation.warehouse_id || firstLocation.warehouseCode;
|
||
const floor = firstLocation.floor;
|
||
const zone = firstLocation.zone;
|
||
|
||
if (warehouseCode && floor && zone) {
|
||
console.log("🔍 [handleRackStructureBatchSave] 기존 데이터 중복 체크:", { warehouseCode, floor, zone });
|
||
|
||
try {
|
||
const existingResponse = await DynamicFormApi.getTableData(tableName, {
|
||
filters: {
|
||
warehouse_code: warehouseCode,
|
||
floor: floor,
|
||
zone: zone,
|
||
},
|
||
page: 1,
|
||
pageSize: 1000,
|
||
});
|
||
|
||
// API 응답 구조에 따라 데이터 추출
|
||
const responseData = existingResponse.data as any;
|
||
const existingData = responseData?.data || responseData || [];
|
||
|
||
if (Array.isArray(existingData) && existingData.length > 0) {
|
||
// 중복되는 위치 확인
|
||
const existingSet = new Set(existingData.map((loc: any) => `${loc.row_num}-${loc.level_num}`));
|
||
|
||
const duplicates = locations.filter((loc) => {
|
||
const key = `${loc.row_num || loc.rowNum}-${loc.level_num || loc.levelNum}`;
|
||
return existingSet.has(key);
|
||
});
|
||
|
||
if (duplicates.length > 0) {
|
||
const duplicateInfo = duplicates
|
||
.slice(0, 5)
|
||
.map((d) => `${d.row_num || d.rowNum}열 ${d.level_num || d.levelNum}단`)
|
||
.join(", ");
|
||
|
||
const moreCount = duplicates.length > 5 ? ` 외 ${duplicates.length - 5}개` : "";
|
||
|
||
alert(
|
||
`이미 등록된 위치가 있습니다!\n\n중복 위치: ${duplicateInfo}${moreCount}\n\n해당 위치를 제외하거나 기존 데이터를 삭제해주세요.`,
|
||
);
|
||
return false;
|
||
}
|
||
}
|
||
} catch (checkError) {
|
||
console.warn("⚠️ [handleRackStructureBatchSave] 중복 체크 실패 (저장 계속 진행):", checkError);
|
||
}
|
||
}
|
||
|
||
// 각 위치 데이터를 그대로 저장 (렉 구조 컴포넌트에서 이미 테이블 컬럼명으로 생성됨)
|
||
const recordsToInsert = locations.map((loc) => {
|
||
// 렉 구조 컴포넌트에서 생성된 데이터를 그대로 사용
|
||
// 새로운 형식(스네이크 케이스)과 기존 형식(카멜 케이스) 모두 지원
|
||
const record: Record<string, any> = {
|
||
// 렉 구조에서 생성된 필드 (이미 테이블 컬럼명과 동일)
|
||
location_code: loc.location_code || loc.locationCode,
|
||
location_name: loc.location_name || loc.locationName,
|
||
row_num: loc.row_num || String(loc.rowNum),
|
||
level_num: loc.level_num || String(loc.levelNum),
|
||
// 창고 정보 (렉 구조 컴포넌트에서 전달) - DB 컬럼명은 warehouse_code
|
||
warehouse_code: loc.warehouse_code || loc.warehouse_id || loc.warehouseCode,
|
||
warehouse_name: loc.warehouse_name || loc.warehouseName,
|
||
// 위치 정보 (렉 구조 컴포넌트에서 전달)
|
||
floor: loc.floor,
|
||
zone: loc.zone,
|
||
location_type: loc.location_type || loc.locationType,
|
||
status: loc.status || "사용",
|
||
// 사용자 정보 추가
|
||
writer: userId,
|
||
company_code: companyCode,
|
||
};
|
||
|
||
return record;
|
||
});
|
||
|
||
console.log("🏗️ [handleRackStructureBatchSave] 저장할 레코드 수:", recordsToInsert.length);
|
||
console.log("🏗️ [handleRackStructureBatchSave] 첫 번째 레코드 예시:", recordsToInsert[0]);
|
||
|
||
// 일괄 INSERT 실행
|
||
try {
|
||
let successCount = 0;
|
||
let errorCount = 0;
|
||
const errors: string[] = [];
|
||
|
||
for (let i = 0; i < recordsToInsert.length; i++) {
|
||
const record = recordsToInsert[i];
|
||
try {
|
||
console.log(`🏗️ [handleRackStructureBatchSave] 저장 중 (${i + 1}/${recordsToInsert.length}):`, record);
|
||
|
||
const result = await DynamicFormApi.saveFormData({
|
||
screenId,
|
||
tableName,
|
||
data: record,
|
||
});
|
||
|
||
console.log(`🏗️ [handleRackStructureBatchSave] API 응답 (${i + 1}):`, result);
|
||
|
||
if (result.success) {
|
||
successCount++;
|
||
} else {
|
||
errorCount++;
|
||
const errorMsg = result.message || result.error || "알 수 없는 오류";
|
||
errors.push(errorMsg);
|
||
console.error(`❌ [handleRackStructureBatchSave] 저장 실패 (${i + 1}):`, errorMsg);
|
||
}
|
||
} catch (error: any) {
|
||
errorCount++;
|
||
const errorMsg = error.message || "저장 중 오류 발생";
|
||
errors.push(errorMsg);
|
||
console.error(`❌ [handleRackStructureBatchSave] 예외 발생 (${i + 1}):`, error);
|
||
}
|
||
}
|
||
|
||
console.log("🏗️ [handleRackStructureBatchSave] 저장 완료:", {
|
||
successCount,
|
||
errorCount,
|
||
errors: errors.slice(0, 5), // 처음 5개 오류만 로그
|
||
});
|
||
|
||
if (errorCount > 0) {
|
||
if (successCount > 0) {
|
||
alert(`${successCount}개 저장 완료, ${errorCount}개 저장 실패\n\n오류: ${errors.slice(0, 3).join("\n")}`);
|
||
} else {
|
||
throw new Error(`저장 실패: ${errors[0]}`);
|
||
}
|
||
} else {
|
||
alert(`${successCount}개의 위치가 성공적으로 등록되었습니다.`);
|
||
}
|
||
|
||
// 성공 후 새로고침
|
||
if (context.onRefresh) {
|
||
context.onRefresh();
|
||
}
|
||
|
||
// 모달 닫기
|
||
if (context.onClose) {
|
||
context.onClose();
|
||
}
|
||
|
||
return successCount > 0;
|
||
} catch (error: any) {
|
||
console.error("🏗️ [handleRackStructureBatchSave] 일괄 저장 오류:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 🆕 Universal Form Modal 테이블 섹션 병합 저장 처리
|
||
* 범용_폼_모달 내부의 공통 필드 + _tableSection_ 데이터를 병합하여 품목별로 저장
|
||
* 수정 모드: INSERT/UPDATE/DELETE 지원
|
||
* 🆕 섹션별 저장 테이블(targetTable) 지원 추가
|
||
*/
|
||
private static async handleUniversalFormModalTableSectionSave(
|
||
config: ButtonActionConfig,
|
||
context: ButtonActionContext,
|
||
formData: Record<string, any>,
|
||
): Promise<{ handled: boolean; success: boolean }> {
|
||
const { tableName, screenId } = context;
|
||
|
||
// 범용_폼_모달 키 찾기 (컬럼명에 따라 다를 수 있음)
|
||
const universalFormModalKey = Object.keys(formData).find((key) => {
|
||
const value = formData[key];
|
||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||
// _tableSection_ 키가 있는지 확인
|
||
return Object.keys(value).some((k) => k.startsWith("_tableSection_"));
|
||
});
|
||
|
||
if (!universalFormModalKey) {
|
||
return { handled: false, success: false };
|
||
}
|
||
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] Universal Form Modal 감지:", universalFormModalKey);
|
||
|
||
const modalData = formData[universalFormModalKey];
|
||
|
||
// 🆕 universal-form-modal 컴포넌트 설정 가져오기
|
||
// 1. componentConfigs에서 컴포넌트 ID로 찾기
|
||
// 2. allComponents에서 columnName으로 찾기
|
||
// 3. 화면 레이아웃 API에서 가져오기
|
||
let modalComponentConfig = context.componentConfigs?.[universalFormModalKey];
|
||
|
||
// componentConfigs에서 직접 찾지 못한 경우, allComponents에서 columnName으로 찾기
|
||
if (!modalComponentConfig && context.allComponents) {
|
||
const modalComponent = context.allComponents.find(
|
||
(comp: any) =>
|
||
comp.columnName === universalFormModalKey || comp.properties?.columnName === universalFormModalKey,
|
||
);
|
||
if (modalComponent) {
|
||
modalComponentConfig = modalComponent.componentConfig || modalComponent.properties?.componentConfig;
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] allComponents에서 설정 찾음:", modalComponent.id);
|
||
}
|
||
}
|
||
|
||
// 🆕 아직도 설정을 찾지 못했으면 화면 레이아웃 API에서 가져오기
|
||
if (!modalComponentConfig && screenId) {
|
||
try {
|
||
console.log("🔍 [handleUniversalFormModalTableSectionSave] 화면 레이아웃 API에서 설정 조회:", screenId);
|
||
const { screenApi } = await import("@/lib/api/screen");
|
||
const layoutData = await screenApi.getLayout(screenId);
|
||
|
||
if (layoutData && layoutData.components) {
|
||
// 레이아웃에서 universal-form-modal 컴포넌트 찾기
|
||
const modalLayout = (layoutData.components as any[]).find(
|
||
(comp) =>
|
||
comp.properties?.columnName === universalFormModalKey || comp.columnName === universalFormModalKey,
|
||
);
|
||
if (modalLayout) {
|
||
modalComponentConfig = modalLayout.properties?.componentConfig || modalLayout.componentConfig;
|
||
console.log(
|
||
"🎯 [handleUniversalFormModalTableSectionSave] 화면 레이아웃에서 설정 찾음:",
|
||
modalLayout.componentId,
|
||
);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn("⚠️ [handleUniversalFormModalTableSectionSave] 화면 레이아웃 조회 실패:", error);
|
||
}
|
||
}
|
||
|
||
const sections: any[] = modalComponentConfig?.sections || [];
|
||
const saveConfig = modalComponentConfig?.saveConfig || {};
|
||
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] 컴포넌트 설정:", {
|
||
hasComponentConfig: !!modalComponentConfig,
|
||
sectionsCount: sections.length,
|
||
mainTableName: saveConfig.tableName || tableName,
|
||
sectionSaveModes: saveConfig.sectionSaveModes,
|
||
sectionDetails: sections.map((s: any) => ({
|
||
id: s.id,
|
||
type: s.type,
|
||
targetTable: s.tableConfig?.saveConfig?.targetTable,
|
||
})),
|
||
});
|
||
|
||
// _tableSection_ 데이터 추출
|
||
const tableSectionData: Record<string, any[]> = {};
|
||
const commonFieldsData: Record<string, any> = {};
|
||
|
||
// 🆕 원본 그룹 데이터 추출 (수정 모드에서 UPDATE/DELETE 추적용)
|
||
// modalData 내부 또는 최상위 formData에서 찾음
|
||
const originalGroupedData: any[] = modalData._originalGroupedData || formData._originalGroupedData || [];
|
||
|
||
for (const [key, value] of Object.entries(modalData)) {
|
||
if (key.startsWith("_tableSection_")) {
|
||
const sectionId = key.replace("_tableSection_", "");
|
||
tableSectionData[sectionId] = value as any[];
|
||
} else if (!key.startsWith("_")) {
|
||
// _로 시작하지 않는 필드는 공통 필드로 처리
|
||
commonFieldsData[key] = value;
|
||
}
|
||
}
|
||
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] 데이터 분리:", {
|
||
commonFields: Object.keys(commonFieldsData),
|
||
tableSections: Object.keys(tableSectionData),
|
||
tableSectionCounts: Object.entries(tableSectionData).map(([k, v]) => ({ [k]: v.length })),
|
||
originalGroupedDataCount: originalGroupedData.length,
|
||
isEditMode: originalGroupedData.length > 0,
|
||
});
|
||
|
||
// 테이블 섹션 데이터가 없고 원본 데이터도 없으면 처리하지 않음
|
||
const hasTableSectionData = Object.values(tableSectionData).some((arr) => arr.length > 0);
|
||
if (!hasTableSectionData && originalGroupedData.length === 0) {
|
||
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 테이블 섹션 데이터 없음 - 일반 저장으로 전환");
|
||
return { handled: false, success: false };
|
||
}
|
||
|
||
// 🎯 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
|
||
console.log("🔍 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 체크 시작");
|
||
|
||
const fieldsWithNumbering: Record<string, string> = {};
|
||
|
||
// commonFieldsData와 modalData에서 채번 규칙이 설정된 필드 찾기
|
||
for (const [key, value] of Object.entries(modalData)) {
|
||
if (key.endsWith("_numberingRuleId") && value) {
|
||
const fieldName = key.replace("_numberingRuleId", "");
|
||
fieldsWithNumbering[fieldName] = value as string;
|
||
console.log(`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견: ${fieldName} → 규칙 ${value}`);
|
||
}
|
||
}
|
||
|
||
// formData에서도 확인 (모달 외부에 있을 수 있음)
|
||
for (const [key, value] of Object.entries(formData)) {
|
||
if (key.endsWith("_numberingRuleId") && value && !fieldsWithNumbering[key.replace("_numberingRuleId", "")]) {
|
||
const fieldName = key.replace("_numberingRuleId", "");
|
||
fieldsWithNumbering[fieldName] = value as string;
|
||
console.log(
|
||
`🎯 [handleUniversalFormModalTableSectionSave] 채번 필드 발견 (formData): ${fieldName} → 규칙 ${value}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
console.log("📋 [handleUniversalFormModalTableSectionSave] 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||
|
||
// 🔥 저장 시점에 allocateCode 호출하여 실제 순번 증가
|
||
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 시작 (allocateCode 호출)");
|
||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||
|
||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||
try {
|
||
console.log(
|
||
`🔄 [handleUniversalFormModalTableSectionSave] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`,
|
||
);
|
||
const allocateResult = await allocateNumberingCode(ruleId);
|
||
|
||
if (allocateResult.success && allocateResult.data?.generatedCode) {
|
||
const newCode = allocateResult.data.generatedCode;
|
||
console.log(
|
||
`✅ [handleUniversalFormModalTableSectionSave] ${fieldName} 새 코드 할당: ${commonFieldsData[fieldName]} → ${newCode}`,
|
||
);
|
||
commonFieldsData[fieldName] = newCode;
|
||
} else {
|
||
console.warn(
|
||
`⚠️ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 실패, 기존 값 유지:`,
|
||
allocateResult.error,
|
||
);
|
||
}
|
||
} catch (allocateError) {
|
||
console.error(`❌ [handleUniversalFormModalTableSectionSave] ${fieldName} 코드 할당 오류:`, allocateError);
|
||
// 오류 시 기존 값 유지
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("✅ [handleUniversalFormModalTableSectionSave] 채번 규칙 할당 완료");
|
||
|
||
try {
|
||
// 사용자 정보 추가
|
||
if (!context.userId) {
|
||
throw new Error("사용자 정보를 불러올 수 없습니다. 다시 로그인해주세요.");
|
||
}
|
||
|
||
const userInfo = {
|
||
writer: context.userId,
|
||
created_by: context.userId,
|
||
updated_by: context.userId,
|
||
company_code: context.companyCode || "",
|
||
};
|
||
|
||
let insertedCount = 0;
|
||
let updatedCount = 0;
|
||
let deletedCount = 0;
|
||
let mainRecordId: number | null = null;
|
||
|
||
// 🆕 먼저 메인 테이블에 공통 데이터 저장 (별도 테이블이 있는 경우에만)
|
||
const hasSeparateTargetTable = sections.some(
|
||
(s) =>
|
||
s.type === "table" &&
|
||
s.tableConfig?.saveConfig?.targetTable &&
|
||
s.tableConfig.saveConfig.targetTable !== tableName,
|
||
);
|
||
|
||
if (hasSeparateTargetTable && Object.keys(commonFieldsData).length > 0) {
|
||
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블에 공통 데이터 저장:", tableName);
|
||
|
||
const mainRowToSave = { ...commonFieldsData, ...userInfo };
|
||
|
||
// 메타데이터 제거
|
||
Object.keys(mainRowToSave).forEach((key) => {
|
||
if (key.startsWith("_")) {
|
||
delete mainRowToSave[key];
|
||
}
|
||
});
|
||
|
||
console.log("📦 [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 데이터:", mainRowToSave);
|
||
|
||
const mainSaveResult = await DynamicFormApi.saveFormData({
|
||
screenId: screenId!,
|
||
tableName: tableName!,
|
||
data: mainRowToSave,
|
||
});
|
||
|
||
if (!mainSaveResult.success) {
|
||
throw new Error(mainSaveResult.message || "메인 데이터 저장 실패");
|
||
}
|
||
|
||
mainRecordId = mainSaveResult.data?.id || null;
|
||
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 저장 완료, ID:", mainRecordId);
|
||
}
|
||
|
||
// 각 테이블 섹션 처리
|
||
for (const [sectionId, currentItems] of Object.entries(tableSectionData)) {
|
||
console.log(
|
||
`🔄 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 처리 시작: ${currentItems.length}개 품목`,
|
||
);
|
||
|
||
// 🆕 해당 섹션의 설정 찾기
|
||
const sectionConfig = sections.find((s) => s.id === sectionId);
|
||
const targetTableName = sectionConfig?.tableConfig?.saveConfig?.targetTable;
|
||
|
||
// 🆕 실제 저장할 테이블 결정
|
||
// - targetTable이 있으면 해당 테이블에 저장
|
||
// - targetTable이 없으면 메인 테이블에 저장
|
||
const saveTableName = targetTableName || tableName!;
|
||
|
||
console.log(`📊 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 저장 테이블:`, {
|
||
targetTableName,
|
||
saveTableName,
|
||
isMainTable: saveTableName === tableName,
|
||
});
|
||
|
||
// 1️⃣ 신규 품목 INSERT (id가 없는 항목)
|
||
const newItems = currentItems.filter((item) => !item.id);
|
||
for (const item of newItems) {
|
||
const rowToSave = { ...commonFieldsData, ...item, ...userInfo };
|
||
|
||
// 내부 메타데이터 제거
|
||
Object.keys(rowToSave).forEach((key) => {
|
||
if (key.startsWith("_")) {
|
||
delete rowToSave[key];
|
||
}
|
||
});
|
||
|
||
// 🆕 메인 레코드 ID 연결 (별도 테이블에 저장하는 경우)
|
||
if (targetTableName && mainRecordId && saveConfig.primaryKeyColumn) {
|
||
rowToSave[saveConfig.primaryKeyColumn] = mainRecordId;
|
||
}
|
||
|
||
console.log("➕ [INSERT] 신규 품목:", { tableName: saveTableName, data: rowToSave });
|
||
|
||
const saveResult = await DynamicFormApi.saveFormData({
|
||
screenId: screenId!,
|
||
tableName: saveTableName,
|
||
data: rowToSave,
|
||
});
|
||
|
||
if (!saveResult.success) {
|
||
throw new Error(saveResult.message || "신규 품목 저장 실패");
|
||
}
|
||
|
||
insertedCount++;
|
||
}
|
||
|
||
// 2️⃣ 기존 품목 UPDATE (id가 있는 항목, 변경된 경우만)
|
||
const existingItems = currentItems.filter((item) => item.id);
|
||
for (const item of existingItems) {
|
||
const originalItem = originalGroupedData.find((orig) => orig.id === item.id);
|
||
|
||
if (!originalItem) {
|
||
// 🆕 폴백 로직: 원본 데이터가 없어도 id가 있으면 UPDATE 시도
|
||
// originalGroupedData 전달이 누락된 경우를 처리
|
||
console.warn(`⚠️ [UPDATE] 원본 데이터 없음 - id가 있으므로 UPDATE 시도 (폴백): id=${item.id}`);
|
||
|
||
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함
|
||
// item에 있는 기존 값(예: manager_id=123)이 commonFieldsData의 새 값(manager_id=234)을 덮어쓰지 않도록
|
||
// 순서: item(기존) → commonFieldsData(새로 입력) → userInfo(메타데이터)
|
||
const rowToUpdate = { ...item, ...commonFieldsData, ...userInfo };
|
||
Object.keys(rowToUpdate).forEach((key) => {
|
||
if (key.startsWith("_")) {
|
||
delete rowToUpdate[key];
|
||
}
|
||
});
|
||
|
||
console.log("📝 [UPDATE 폴백] 저장할 데이터:", {
|
||
id: item.id,
|
||
tableName: saveTableName,
|
||
commonFieldsData,
|
||
itemFields: Object.keys(item).filter(k => !k.startsWith("_")),
|
||
rowToUpdate,
|
||
});
|
||
|
||
// id를 유지하고 UPDATE 실행
|
||
const updateResult = await DynamicFormApi.updateFormData(item.id, {
|
||
tableName: saveTableName,
|
||
data: rowToUpdate,
|
||
});
|
||
|
||
if (!updateResult.success) {
|
||
throw new Error(updateResult.message || "품목 수정 실패");
|
||
}
|
||
|
||
updatedCount++;
|
||
continue;
|
||
}
|
||
|
||
// 변경 사항 확인 (공통 필드 포함)
|
||
// ⚠️ 중요: commonFieldsData가 item보다 우선순위가 높아야 함 (새로 입력한 값이 기존 값을 덮어씀)
|
||
const currentDataWithCommon = { ...item, ...commonFieldsData };
|
||
const hasChanges = this.checkForChanges(originalItem, currentDataWithCommon);
|
||
|
||
if (hasChanges) {
|
||
console.log(`🔄 [UPDATE] 품목 수정: id=${item.id}, tableName=${saveTableName}`);
|
||
|
||
// 변경된 필드만 추출하여 부분 업데이트
|
||
const updateResult = await DynamicFormApi.updateFormDataPartial(
|
||
item.id,
|
||
originalItem,
|
||
currentDataWithCommon,
|
||
saveTableName,
|
||
);
|
||
|
||
if (!updateResult.success) {
|
||
throw new Error(updateResult.message || "품목 수정 실패");
|
||
}
|
||
|
||
updatedCount++;
|
||
} else {
|
||
console.log(`⏭️ [SKIP] 변경 없음: id=${item.id}`);
|
||
}
|
||
}
|
||
|
||
// 3️⃣ 삭제된 품목 DELETE (원본에는 있지만 현재에는 없는 항목)
|
||
// ⚠️ id 타입 통일: 문자열로 변환하여 비교 (숫자 vs 문자열 불일치 방지)
|
||
const currentIds = new Set(currentItems.map((item) => String(item.id)).filter(Boolean));
|
||
const deletedItems = originalGroupedData.filter((orig) => orig.id && !currentIds.has(String(orig.id)));
|
||
|
||
for (const deletedItem of deletedItems) {
|
||
console.log(`🗑️ [DELETE] 품목 삭제: id=${deletedItem.id}, tableName=${saveTableName}`);
|
||
|
||
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(deletedItem.id, saveTableName);
|
||
|
||
if (!deleteResult.success) {
|
||
throw new Error(deleteResult.message || "품목 삭제 실패");
|
||
}
|
||
|
||
deletedCount++;
|
||
}
|
||
}
|
||
|
||
// 결과 메시지 생성
|
||
const resultParts: string[] = [];
|
||
if (mainRecordId) resultParts.push("메인 데이터 저장");
|
||
if (insertedCount > 0) resultParts.push(`${insertedCount}개 추가`);
|
||
if (updatedCount > 0) resultParts.push(`${updatedCount}개 수정`);
|
||
if (deletedCount > 0) resultParts.push(`${deletedCount}개 삭제`);
|
||
|
||
const resultMessage = resultParts.length > 0 ? resultParts.join(", ") : "변경 사항 없음";
|
||
|
||
console.log(`✅ [handleUniversalFormModalTableSectionSave] 완료: ${resultMessage}`);
|
||
toast.success(`저장 완료: ${resultMessage}`);
|
||
|
||
// 🆕 저장 성공 후 제어 관리 실행 (다중 테이블 저장 시 소스 테이블과 일치하는 섹션만 실행)
|
||
if (config.enableDataflowControl && config.dataflowConfig?.flowConfig?.flowId) {
|
||
const flowId = config.dataflowConfig.flowConfig.flowId;
|
||
console.log("🎯 [handleUniversalFormModalTableSectionSave] 제어 관리 실행 시작:", { flowId });
|
||
|
||
try {
|
||
// 플로우 소스 테이블 조회
|
||
const { getFlowSourceTable } = await import("@/lib/api/nodeFlows");
|
||
const flowSourceInfo = await getFlowSourceTable(flowId);
|
||
|
||
console.log("📊 [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블:", flowSourceInfo);
|
||
|
||
if (flowSourceInfo.sourceTable) {
|
||
// 각 섹션 확인하여 소스 테이블과 일치하는 섹션 찾기
|
||
let controlExecuted = false;
|
||
|
||
for (const [sectionId, sectionItems] of Object.entries(tableSectionData)) {
|
||
const sectionConfig = sections.find((s: any) => s.id === sectionId);
|
||
const sectionTargetTable = sectionConfig?.tableConfig?.saveConfig?.targetTable || tableName;
|
||
|
||
console.log(`🔍 [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} 테이블 비교:`, {
|
||
sectionTargetTable,
|
||
flowSourceTable: flowSourceInfo.sourceTable,
|
||
isMatch: sectionTargetTable === flowSourceInfo.sourceTable,
|
||
});
|
||
|
||
// 소스 테이블과 일치하는 섹션만 제어 실행
|
||
if (sectionTargetTable === flowSourceInfo.sourceTable && sectionItems.length > 0) {
|
||
console.log(
|
||
`✅ [handleUniversalFormModalTableSectionSave] 섹션 ${sectionId} → 플로우 소스 테이블 일치! 제어 실행`,
|
||
);
|
||
|
||
// 공통 필드 + 해당 섹션 데이터 병합하여 sourceData 생성
|
||
const sourceData = sectionItems.map((item: any) => ({
|
||
...commonFieldsData,
|
||
...item,
|
||
}));
|
||
|
||
console.log(
|
||
`📦 [handleUniversalFormModalTableSectionSave] 제어 전달 데이터: ${sourceData.length}건`,
|
||
sourceData[0],
|
||
);
|
||
|
||
// 제어 관리용 컨텍스트 생성
|
||
const controlContext: ButtonActionContext = {
|
||
...context,
|
||
selectedRowsData: sourceData,
|
||
formData: commonFieldsData,
|
||
};
|
||
|
||
// 제어 관리 실행
|
||
await this.executeAfterSaveControl(config, controlContext);
|
||
controlExecuted = true;
|
||
break; // 첫 번째 매칭 섹션만 실행
|
||
}
|
||
}
|
||
|
||
// 매칭되는 섹션이 없으면 메인 테이블 확인
|
||
if (!controlExecuted && tableName === flowSourceInfo.sourceTable) {
|
||
console.log("✅ [handleUniversalFormModalTableSectionSave] 메인 테이블 일치! 공통 필드로 제어 실행");
|
||
|
||
const controlContext: ButtonActionContext = {
|
||
...context,
|
||
selectedRowsData: [commonFieldsData],
|
||
formData: commonFieldsData,
|
||
};
|
||
|
||
await this.executeAfterSaveControl(config, controlContext);
|
||
}
|
||
} else {
|
||
console.log("⚠️ [handleUniversalFormModalTableSectionSave] 플로우 소스 테이블 없음 - 제어 스킵");
|
||
}
|
||
} catch (controlError) {
|
||
console.error("❌ [handleUniversalFormModalTableSectionSave] 제어 관리 실행 오류:", controlError);
|
||
// 제어 관리 실패는 저장 성공에 영향주지 않음
|
||
}
|
||
}
|
||
|
||
// 저장 성공 이벤트 발생
|
||
window.dispatchEvent(new CustomEvent("saveSuccess"));
|
||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||
// EditModal 닫기 이벤트 발생
|
||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||
|
||
return { handled: true, success: true };
|
||
} catch (error: any) {
|
||
console.error("❌ [handleUniversalFormModalTableSectionSave] 저장 오류:", error);
|
||
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||
return { handled: true, success: false };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 두 객체 간 변경 사항 확인
|
||
*/
|
||
private static checkForChanges(original: Record<string, any>, current: Record<string, any>): boolean {
|
||
// 비교할 필드 목록 (메타데이터 제외)
|
||
const fieldsToCompare = new Set([
|
||
...Object.keys(original).filter((k) => !k.startsWith("_")),
|
||
...Object.keys(current).filter((k) => !k.startsWith("_")),
|
||
]);
|
||
|
||
for (const field of fieldsToCompare) {
|
||
// 시스템 필드는 비교에서 제외
|
||
if (["created_date", "updated_date", "created_by", "updated_by", "writer"].includes(field)) {
|
||
continue;
|
||
}
|
||
|
||
const originalValue = original[field];
|
||
const currentValue = current[field];
|
||
|
||
// null/undefined 통일 처리
|
||
const normalizedOriginal = originalValue === null || originalValue === undefined ? "" : String(originalValue);
|
||
const normalizedCurrent = currentValue === null || currentValue === undefined ? "" : String(currentValue);
|
||
|
||
if (normalizedOriginal !== normalizedCurrent) {
|
||
console.log(` 📝 변경 감지: ${field} = "${normalizedOriginal}" → "${normalizedCurrent}"`);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
||
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
||
*/
|
||
private static async handleBatchSave(
|
||
config: ButtonActionConfig,
|
||
context: ButtonActionContext,
|
||
selectedItemsKeys: string[],
|
||
): Promise<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 allGroupsEmpty = entryArrays.every((arr) => arr.length === 0);
|
||
|
||
let combinations: any[][];
|
||
if (allGroupsEmpty) {
|
||
// 🆕 모든 그룹이 비어있으면 빈 조합 하나 생성 (품목 기본 정보만으로 저장)
|
||
console.log("📝 [handleBatchSave] 모든 그룹이 비어있음 - 기본 레코드 생성");
|
||
combinations = [[]];
|
||
} else {
|
||
// 빈 그룹을 필터링하여 카티션 곱 계산 (빈 그룹은 무시)
|
||
const nonEmptyArrays = entryArrays.filter((arr) => arr.length > 0);
|
||
combinations = nonEmptyArrays.length > 0 ? cartesianProduct(nonEmptyArrays) : [[]];
|
||
}
|
||
|
||
// 각 조합을 개별 레코드로 저장
|
||
for (let i = 0; i < combinations.length; i++) {
|
||
const combination = combinations[i];
|
||
try {
|
||
// 🆕 부모 데이터 매핑 적용
|
||
const mappedData: any = {};
|
||
|
||
// 1. parentDataMapping 설정이 있으면 적용
|
||
if (parentDataMapping.length > 0) {
|
||
for (const mapping of parentDataMapping) {
|
||
let sourceData: any;
|
||
const sourceTableName = mapping.sourceTable;
|
||
const selectedItemTable = componentConfig?.sourceTable;
|
||
|
||
if (sourceTableName === selectedItemTable) {
|
||
sourceData = item.originalData;
|
||
} else {
|
||
const tableData = modalDataStore[sourceTableName];
|
||
if (tableData && Array.isArray(tableData) && tableData.length > 0) {
|
||
sourceData = tableData[0];
|
||
} else {
|
||
sourceData = parentData;
|
||
}
|
||
}
|
||
|
||
const sourceValue = sourceData[mapping.sourceField];
|
||
|
||
if (sourceValue !== undefined && sourceValue !== null) {
|
||
mappedData[mapping.targetField] = sourceValue;
|
||
} else if (mapping.defaultValue !== undefined) {
|
||
mappedData[mapping.targetField] = mapping.defaultValue;
|
||
}
|
||
}
|
||
} else {
|
||
// 🔧 parentDataMapping 설정이 없는 경우 기본 매핑 (하위 호환성)
|
||
if (item.originalData.id) {
|
||
mappedData.item_id = item.originalData.id;
|
||
}
|
||
|
||
if (parentData.id || parentData.customer_id) {
|
||
mappedData.customer_id = parentData.customer_id || parentData.id;
|
||
}
|
||
}
|
||
|
||
// 공통 필드 복사 (company_code, currency_code 등)
|
||
if (item.originalData.company_code && !mappedData.company_code) {
|
||
mappedData.company_code = item.originalData.company_code;
|
||
}
|
||
if (item.originalData.currency_code && !mappedData.currency_code) {
|
||
mappedData.currency_code = item.originalData.currency_code;
|
||
}
|
||
|
||
// 원본 데이터로 시작 (매핑된 데이터 사용)
|
||
let mergedData = { ...mappedData };
|
||
|
||
// 각 그룹의 항목 데이터를 순차적으로 병합
|
||
for (let j = 0; j < combination.length; j++) {
|
||
const entry = combination[j];
|
||
const { id, ...entryData } = entry; // id 제외
|
||
mergedData = { ...mergedData, ...entryData };
|
||
}
|
||
|
||
// 🆕 조합 저장 시 id 필드 제거 (각 조합이 독립된 새 레코드가 되도록)
|
||
const { id: _removedId, ...dataWithoutId } = mergedData;
|
||
|
||
// 사용자 정보 추가
|
||
if (!context.userId) {
|
||
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
||
}
|
||
|
||
const writerValue = context.userId;
|
||
const companyCodeValue = context.companyCode || "";
|
||
|
||
const dataWithUserInfo = {
|
||
...dataWithoutId,
|
||
writer: dataWithoutId.writer || writerValue,
|
||
created_by: writerValue,
|
||
updated_by: writerValue,
|
||
company_code: dataWithoutId.company_code || companyCodeValue,
|
||
};
|
||
|
||
// INSERT 실행
|
||
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||
const saveResult = await DynamicFormApi.saveFormData({
|
||
screenId,
|
||
tableName,
|
||
data: dataWithUserInfo,
|
||
});
|
||
|
||
if (saveResult.success) {
|
||
successCount++;
|
||
} else {
|
||
failCount++;
|
||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${saveResult.message}`);
|
||
}
|
||
} catch (error: any) {
|
||
failCount++;
|
||
errors.push(`품목 ${item.id} > 조합 ${i + 1}: ${error.message}`);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 결과 토스트
|
||
if (failCount === 0) {
|
||
toast.success(`${successCount}개 항목이 저장되었습니다.`);
|
||
} else if (successCount === 0) {
|
||
toast.error(`저장 실패: ${errors.join(", ")}`);
|
||
return false;
|
||
} else {
|
||
toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
|
||
}
|
||
|
||
// 테이블과 플로우 새로고침
|
||
context.onRefresh?.();
|
||
context.onFlowRefresh?.();
|
||
|
||
// 저장 성공 후 이벤트 발생
|
||
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error("배치 저장 오류:", error);
|
||
toast.error(`저장 오류: ${error.message}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 삭제 액션 처리
|
||
*/
|
||
private static async handleDelete(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
const { formData, tableName, screenId, selectedRowsData, flowSelectedData } = context;
|
||
|
||
try {
|
||
// 플로우 선택 데이터 우선 사용
|
||
const dataToDelete = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
|
||
|
||
console.log("🔍 handleDelete - 데이터 소스 확인:", {
|
||
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
|
||
flowSelectedDataLength: flowSelectedData?.length || 0,
|
||
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
|
||
selectedRowsDataLength: selectedRowsData?.length || 0,
|
||
dataToDeleteLength: dataToDelete?.length || 0,
|
||
});
|
||
|
||
// 다중 선택된 데이터가 있는 경우
|
||
if (dataToDelete && dataToDelete.length > 0) {
|
||
console.log(`다중 삭제 액션 실행: ${dataToDelete.length}개 항목`, dataToDelete);
|
||
|
||
// 테이블의 기본키 조회
|
||
let primaryKeys: string[] = [];
|
||
if (tableName) {
|
||
try {
|
||
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
||
if (primaryKeysResult.success && primaryKeysResult.data) {
|
||
primaryKeys = primaryKeysResult.data;
|
||
console.log(`🔑 테이블 ${tableName}의 기본키:`, primaryKeys);
|
||
}
|
||
} catch (error) {
|
||
console.warn("기본키 조회 실패, 폴백 방법 사용:", error);
|
||
}
|
||
}
|
||
|
||
// 각 선택된 항목을 삭제
|
||
for (const rowData of dataToDelete) {
|
||
let deleteId: any = null;
|
||
|
||
// 1순위: 데이터베이스에서 조회한 기본키 사용
|
||
if (primaryKeys.length > 0) {
|
||
const primaryKey = primaryKeys[0]; // 첫 번째 기본키 사용
|
||
deleteId = rowData[primaryKey];
|
||
console.log(`📊 기본키 ${primaryKey}로 ID 추출:`, deleteId);
|
||
}
|
||
|
||
// 2순위: 폴백 - 일반적인 ID 필드명들 시도
|
||
if (!deleteId) {
|
||
deleteId =
|
||
rowData.id ||
|
||
rowData.objid ||
|
||
rowData.pk ||
|
||
rowData.ID ||
|
||
rowData.OBJID ||
|
||
rowData.PK ||
|
||
// 테이블별 기본키 패턴들
|
||
rowData.sales_no ||
|
||
rowData.contract_no ||
|
||
rowData.order_no ||
|
||
rowData.seq_no ||
|
||
rowData.code ||
|
||
rowData.code_id ||
|
||
rowData.user_id ||
|
||
rowData.menu_id;
|
||
|
||
// _no로 끝나는 필드들 찾기
|
||
if (!deleteId) {
|
||
const noField = Object.keys(rowData).find((key) => key.endsWith("_no") && rowData[key]);
|
||
if (noField) deleteId = rowData[noField];
|
||
}
|
||
|
||
// _id로 끝나는 필드들 찾기
|
||
if (!deleteId) {
|
||
const idField = Object.keys(rowData).find((key) => key.endsWith("_id") && rowData[key]);
|
||
if (idField) deleteId = rowData[idField];
|
||
}
|
||
|
||
console.log("🔍 폴백 방법으로 ID 추출:", deleteId);
|
||
}
|
||
|
||
console.log("선택된 행 데이터:", rowData);
|
||
console.log("최종 추출된 deleteId:", deleteId);
|
||
|
||
if (deleteId) {
|
||
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
||
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 연관 데이터 버튼의 선택 데이터로 모달 열기
|
||
* RelatedDataButtons 컴포넌트에서 선택된 버튼 데이터를 모달로 전달
|
||
*/
|
||
private static async handleOpenRelatedModal(
|
||
config: ButtonActionConfig,
|
||
context: ButtonActionContext,
|
||
): Promise<boolean> {
|
||
// 버튼 설정에서 targetScreenId 가져오기 (여러 위치에서 확인)
|
||
const targetScreenId = config.relatedModalConfig?.targetScreenId || config.targetScreenId;
|
||
|
||
console.log("🔍 [openRelatedModal] 설정 확인:", {
|
||
config,
|
||
relatedModalConfig: config.relatedModalConfig,
|
||
targetScreenId: config.targetScreenId,
|
||
finalTargetScreenId: targetScreenId,
|
||
});
|
||
|
||
if (!targetScreenId) {
|
||
console.error("❌ [openRelatedModal] targetScreenId가 설정되지 않았습니다.");
|
||
toast.error("모달 화면 ID가 설정되지 않았습니다.");
|
||
return false;
|
||
}
|
||
|
||
// RelatedDataButtons에서 선택된 데이터 가져오기
|
||
const relatedData = window.__relatedButtonsSelectedData;
|
||
|
||
console.log("🔍 [openRelatedModal] RelatedDataButtons 데이터:", {
|
||
relatedData,
|
||
selectedItem: relatedData?.selectedItem,
|
||
config: relatedData?.config,
|
||
});
|
||
|
||
if (!relatedData?.selectedItem) {
|
||
console.warn("⚠️ [openRelatedModal] 선택된 버튼이 없습니다.");
|
||
toast.warning("먼저 버튼을 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
const { selectedItem, config: relatedConfig } = relatedData;
|
||
|
||
// 데이터 매핑 적용
|
||
const initialData: Record<string, any> = {};
|
||
|
||
console.log("🔍 [openRelatedModal] 매핑 설정:", {
|
||
modalLink: relatedConfig?.modalLink,
|
||
dataMapping: relatedConfig?.modalLink?.dataMapping,
|
||
});
|
||
|
||
if (relatedConfig?.modalLink?.dataMapping && relatedConfig.modalLink.dataMapping.length > 0) {
|
||
relatedConfig.modalLink.dataMapping.forEach((mapping) => {
|
||
console.log("🔍 [openRelatedModal] 매핑 처리:", {
|
||
mapping,
|
||
sourceField: mapping.sourceField,
|
||
targetField: mapping.targetField,
|
||
selectedItemValue: selectedItem.value,
|
||
selectedItemId: selectedItem.id,
|
||
rawDataValue: selectedItem.rawData[mapping.sourceField],
|
||
});
|
||
|
||
if (mapping.sourceField === "value") {
|
||
initialData[mapping.targetField] = selectedItem.value;
|
||
} else if (mapping.sourceField === "id") {
|
||
initialData[mapping.targetField] = selectedItem.id;
|
||
} else if (selectedItem.rawData[mapping.sourceField] !== undefined) {
|
||
initialData[mapping.targetField] = selectedItem.rawData[mapping.sourceField];
|
||
}
|
||
});
|
||
} else {
|
||
// 기본 매핑: id를 routing_version_id로 전달
|
||
console.log("🔍 [openRelatedModal] 기본 매핑 사용");
|
||
initialData["routing_version_id"] = selectedItem.value || selectedItem.id;
|
||
}
|
||
|
||
console.log("📤 [openRelatedModal] 모달 열기:", {
|
||
targetScreenId,
|
||
selectedItem,
|
||
initialData,
|
||
});
|
||
|
||
// 모달 열기 이벤트 발생 (ScreenModal은 editData를 사용)
|
||
window.dispatchEvent(
|
||
new CustomEvent("openScreenModal", {
|
||
detail: {
|
||
screenId: targetScreenId,
|
||
title: config.modalTitle,
|
||
description: config.modalDescription,
|
||
editData: initialData, // ScreenModal은 editData로 폼 데이터를 받음
|
||
onSuccess: () => {
|
||
// 성공 후 데이터 새로고침
|
||
window.dispatchEvent(new CustomEvent("refreshTableData"));
|
||
},
|
||
},
|
||
}),
|
||
);
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 모달 액션 처리
|
||
* 선택된 데이터가 있으면 함께 전달 (출하계획 등에서 사용)
|
||
*/
|
||
private static async handleModal(config: ButtonActionConfig, context: ButtonActionContext): Promise<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 rawParentData = dataRegistry[dataSourceId]?.[0]?.originalData || dataRegistry[dataSourceId]?.[0] || {};
|
||
|
||
// 🆕 필드 매핑 적용 (소스 컬럼 → 타겟 컬럼)
|
||
const parentData = { ...rawParentData };
|
||
if (config.fieldMappings && Array.isArray(config.fieldMappings) && config.fieldMappings.length > 0) {
|
||
console.log("🔄 [openModalWithData] 필드 매핑 적용:", config.fieldMappings);
|
||
|
||
config.fieldMappings.forEach((mapping: { sourceField: string; targetField: string }) => {
|
||
if (mapping.sourceField && mapping.targetField && rawParentData[mapping.sourceField] !== undefined) {
|
||
// 타겟 필드에 소스 필드 값 복사
|
||
parentData[mapping.targetField] = rawParentData[mapping.sourceField];
|
||
console.log(` ✅ ${mapping.sourceField} → ${mapping.targetField}: ${rawParentData[mapping.sourceField]}`);
|
||
}
|
||
});
|
||
}
|
||
|
||
// 🆕 modalDataStore에서 선택된 전체 데이터 가져오기 (RepeatScreenModal에서 사용)
|
||
const modalData = dataRegistry[dataSourceId] || [];
|
||
const selectedData = modalData.map((item: any) => item.originalData || item);
|
||
const selectedIds = selectedData.map((row: any) => row.id).filter(Boolean);
|
||
|
||
console.log("📦 [openModalWithData] 부모 데이터 전달:", {
|
||
dataSourceId,
|
||
rawParentData,
|
||
mappedParentData: parentData,
|
||
fieldMappings: config.fieldMappings,
|
||
selectedDataCount: selectedData.length,
|
||
selectedIds,
|
||
});
|
||
|
||
// 🆕 전역 모달 상태 업데이트를 위한 이벤트 발생 (URL 파라미터 포함)
|
||
const modalEvent = new CustomEvent("openScreenModal", {
|
||
detail: {
|
||
screenId: config.targetScreenId,
|
||
title: finalTitle, // 🆕 동적 제목 사용
|
||
description: description,
|
||
size: config.modalSize || "lg", // 데이터 입력 화면은 기본 large
|
||
urlParams: { dataSourceId }, // 🆕 주 데이터 소스만 전달 (나머지는 modalDataStore에서 자동으로 찾음)
|
||
splitPanelParentData: parentData, // 🆕 부모 데이터 전달 (excludeFilter에서 사용)
|
||
// 🆕 선택된 데이터 전달 (RepeatScreenModal에서 groupedData로 사용)
|
||
selectedData: selectedData,
|
||
selectedIds: selectedIds,
|
||
},
|
||
});
|
||
|
||
window.dispatchEvent(modalEvent);
|
||
|
||
// 성공 메시지 (간단하게)
|
||
toast.success(config.successMessage || "다음 단계로 진행합니다.");
|
||
|
||
return true;
|
||
} else {
|
||
console.error("모달로 열 화면이 지정되지 않았습니다.");
|
||
toast.error("대상 화면이 지정되지 않았습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 새 창 액션 처리
|
||
*/
|
||
private static handleNewWindow(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||
let targetUrl = config.targetUrl;
|
||
|
||
// 화면 ID가 지정된 경우 URL 생성
|
||
if (config.targetScreenId) {
|
||
targetUrl = `/screens/${config.targetScreenId}`;
|
||
}
|
||
|
||
if (targetUrl) {
|
||
const windowFeatures = `width=${config.popupWidth || 800},height=${config.popupHeight || 600},scrollbars=yes,resizable=yes`;
|
||
window.open(targetUrl, "_blank", windowFeatures);
|
||
return true;
|
||
}
|
||
|
||
toast.error("열 페이지가 지정되지 않았습니다.");
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 팝업 액션 처리
|
||
*/
|
||
private static handlePopup(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||
// 팝업은 새 창과 유사하지만 더 작은 크기
|
||
return this.handleNewWindow(
|
||
{
|
||
...config,
|
||
popupWidth: config.popupWidth || 600,
|
||
popupHeight: config.popupHeight || 400,
|
||
},
|
||
context,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 검색 액션 처리
|
||
*/
|
||
private static handleSearch(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||
const { formData, onRefresh } = context;
|
||
|
||
console.log("검색 실행:", formData);
|
||
|
||
// 검색 조건 검증
|
||
const hasSearchCriteria = Object.values(formData).some(
|
||
(value) => value !== null && value !== undefined && value !== "",
|
||
);
|
||
|
||
if (!hasSearchCriteria) {
|
||
toast.warning("검색 조건을 입력해주세요.");
|
||
return false;
|
||
}
|
||
|
||
// 검색 실행 (데이터 새로고침)
|
||
onRefresh?.();
|
||
|
||
// 검색 조건을 URL 파라미터로 추가 (선택사항)
|
||
const searchParams = new URLSearchParams();
|
||
Object.entries(formData).forEach(([key, value]) => {
|
||
if (value !== null && value !== undefined && value !== "") {
|
||
searchParams.set(key, String(value));
|
||
}
|
||
});
|
||
|
||
// URL 업데이트 (히스토리에 추가하지 않음)
|
||
if (searchParams.toString()) {
|
||
const newUrl = `${window.location.pathname}?${searchParams.toString()}`;
|
||
window.history.replaceState({}, "", newUrl);
|
||
}
|
||
|
||
toast.success(config.successMessage || "검색을 실행했습니다.");
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 추가 액션 처리
|
||
*/
|
||
private static handleAdd(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||
console.log("추가 액션 실행:", context);
|
||
// 추가 로직 구현 (예: 새 레코드 생성 폼 열기)
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 편집 액션 처리
|
||
*/
|
||
private static async handleEdit(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
const { selectedRowsData, flowSelectedData } = context;
|
||
|
||
// 플로우 선택 데이터 우선 사용
|
||
const dataToEdit = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
|
||
|
||
// 선택된 데이터가 없는 경우
|
||
if (!dataToEdit || dataToEdit.length === 0) {
|
||
toast.error("수정할 항목을 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
// 편집 화면이 설정되지 않은 경우
|
||
if (!config.targetScreenId) {
|
||
toast.error("수정 폼 화면이 설정되지 않았습니다. 버튼 설정에서 수정 폼 화면을 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
if (dataToEdit.length === 1) {
|
||
// 단일 항목 편집
|
||
const rowData = dataToEdit[0];
|
||
|
||
await this.openEditForm(config, rowData, context);
|
||
} else {
|
||
// 다중 항목 편집 - 현재는 단일 편집만 지원
|
||
toast.error("현재 단일 항목 편집만 지원됩니다. 하나의 항목만 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 편집 폼 열기 (단일 항목)
|
||
*/
|
||
private static async openEditForm(
|
||
config: ButtonActionConfig,
|
||
rowData: any,
|
||
context: ButtonActionContext,
|
||
): Promise<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,
|
||
isCreateMode: boolean = false, // 🆕 복사 모드에서 true로 전달
|
||
): 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: isCreateMode ? (config.editModalTitle || "데이터 복사") : (config.editModalTitle || "데이터 수정"),
|
||
description: description,
|
||
modalSize: config.modalSize || "lg",
|
||
editData: rowData,
|
||
isCreateMode: isCreateMode, // 🆕 복사 모드에서 INSERT로 처리되도록
|
||
groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달
|
||
tableName: context.tableName, // 🆕 테이블명 전달
|
||
buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용)
|
||
buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등)
|
||
onSave: () => {
|
||
context.onRefresh?.();
|
||
},
|
||
},
|
||
});
|
||
|
||
window.dispatchEvent(modalEvent);
|
||
}
|
||
|
||
/**
|
||
* 편집 화면으로 이동
|
||
*/
|
||
private static navigateToEditScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
|
||
const rowId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
|
||
|
||
if (!rowId) {
|
||
toast.error("수정할 항목의 ID를 찾을 수 없습니다.");
|
||
return;
|
||
}
|
||
|
||
const editUrl = `/screens/${config.targetScreenId}?mode=edit&id=${rowId}`;
|
||
console.log("🔄 편집 화면으로 이동:", editUrl);
|
||
|
||
window.location.href = editUrl;
|
||
}
|
||
|
||
/**
|
||
* 복사 액션 처리 (품목코드 초기화)
|
||
*/
|
||
private static async handleCopy(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
try {
|
||
const { selectedRowsData, flowSelectedData } = context;
|
||
|
||
// 플로우 선택 데이터 우선 사용
|
||
const dataToCopy = flowSelectedData && flowSelectedData.length > 0 ? flowSelectedData : selectedRowsData;
|
||
|
||
console.log("📋 handleCopy - 데이터 소스 확인:", {
|
||
hasFlowSelectedData: !!(flowSelectedData && flowSelectedData.length > 0),
|
||
flowSelectedDataLength: flowSelectedData?.length || 0,
|
||
hasSelectedRowsData: !!(selectedRowsData && selectedRowsData.length > 0),
|
||
selectedRowsDataLength: selectedRowsData?.length || 0,
|
||
dataToCopyLength: dataToCopy?.length || 0,
|
||
});
|
||
|
||
// 선택된 데이터가 없는 경우
|
||
if (!dataToCopy || dataToCopy.length === 0) {
|
||
toast.error("복사할 항목을 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
// 복사 화면이 설정되지 않은 경우
|
||
if (!config.targetScreenId) {
|
||
toast.error("복사 폼 화면이 설정되지 않았습니다. 버튼 설정에서 복사 폼 화면을 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
console.log(`📋 복사 액션 실행: ${dataToCopy.length}개 항목`, {
|
||
dataToCopy,
|
||
targetScreenId: config.targetScreenId,
|
||
editMode: config.editMode,
|
||
});
|
||
|
||
if (dataToCopy.length === 1) {
|
||
// 단일 항목 복사
|
||
const rowData = dataToCopy[0];
|
||
console.log("📋 단일 항목 복사:", rowData);
|
||
console.log("📋 원본 데이터 키 목록:", Object.keys(rowData));
|
||
|
||
// 복사 시 제거할 필드들
|
||
const copiedData = { ...rowData };
|
||
const fieldsToRemove = [
|
||
// ID 필드 (새 레코드 생성)
|
||
"id",
|
||
"ID",
|
||
// 날짜 필드 (자동 생성)
|
||
"created_date",
|
||
"createdDate",
|
||
"updated_date",
|
||
"updatedDate",
|
||
"created_at",
|
||
"createdAt",
|
||
"updated_at",
|
||
"updatedAt",
|
||
"reg_date",
|
||
"regDate",
|
||
"mod_date",
|
||
"modDate",
|
||
];
|
||
|
||
// 제거할 필드 삭제
|
||
fieldsToRemove.forEach((field) => {
|
||
if (copiedData[field] !== undefined) {
|
||
delete copiedData[field];
|
||
console.log(`🗑️ 필드 제거: ${field}`);
|
||
}
|
||
});
|
||
|
||
// 품목코드 필드 초기화 (여러 가능한 필드명 확인)
|
||
const itemCodeFields = [
|
||
"item_code",
|
||
"itemCode",
|
||
"item_no",
|
||
"itemNo",
|
||
"item_number",
|
||
"itemNumber",
|
||
"품목코드",
|
||
"품번",
|
||
"code",
|
||
];
|
||
|
||
// 🆕 화면 설정에서 채번 규칙 가져오기
|
||
let screenNumberingRules: Record<string, string> = {};
|
||
if (config.targetScreenId) {
|
||
try {
|
||
const { screenApi } = await import("@/lib/api/screen");
|
||
const layout = await screenApi.getLayout(config.targetScreenId);
|
||
|
||
// 레이아웃에서 채번 규칙이 설정된 컴포넌트 찾기
|
||
const findNumberingRules = (components: any[]): void => {
|
||
for (const comp of components) {
|
||
const compConfig = comp.componentConfig || {};
|
||
// text-input 컴포넌트의 채번 규칙 확인
|
||
if (compConfig.autoGeneration?.type === "numbering_rule" && compConfig.autoGeneration?.options?.numberingRuleId) {
|
||
const columnName = compConfig.columnName || comp.columnName;
|
||
if (columnName) {
|
||
screenNumberingRules[columnName] = compConfig.autoGeneration.options.numberingRuleId;
|
||
console.log(`📋 화면 설정에서 채번 규칙 발견: ${columnName} → ${compConfig.autoGeneration.options.numberingRuleId}`);
|
||
}
|
||
}
|
||
// 중첩된 컴포넌트 확인
|
||
if (comp.children && Array.isArray(comp.children)) {
|
||
findNumberingRules(comp.children);
|
||
}
|
||
}
|
||
};
|
||
|
||
if (layout?.components) {
|
||
findNumberingRules(layout.components);
|
||
}
|
||
console.log("📋 화면 설정에서 찾은 채번 규칙:", screenNumberingRules);
|
||
} catch (error) {
|
||
console.warn("⚠️ 화면 레이아웃 조회 실패:", error);
|
||
}
|
||
}
|
||
|
||
// 품목코드 필드를 찾아서 무조건 공백으로 초기화
|
||
let resetFieldName = "";
|
||
for (const field of itemCodeFields) {
|
||
if (copiedData[field] !== undefined) {
|
||
const originalValue = copiedData[field];
|
||
const ruleIdKey = `${field}_numberingRuleId`;
|
||
|
||
// 1순위: 원본 데이터에서 채번 규칙 ID 확인
|
||
// 2순위: 화면 설정에서 채번 규칙 ID 확인
|
||
const numberingRuleId = rowData[ruleIdKey] || screenNumberingRules[field];
|
||
const hasNumberingRule = numberingRuleId !== undefined && numberingRuleId !== null && numberingRuleId !== "";
|
||
|
||
// 품목코드를 무조건 공백으로 초기화
|
||
copiedData[field] = "";
|
||
|
||
// 채번 규칙 ID가 있으면 복사 (저장 시 자동 생성)
|
||
if (hasNumberingRule) {
|
||
copiedData[ruleIdKey] = numberingRuleId;
|
||
console.log(`✅ 품목코드 초기화 (채번 규칙 있음): ${field} (기존값: ${originalValue})`);
|
||
console.log(`📋 채번 규칙 ID 설정: ${ruleIdKey} = ${numberingRuleId}`);
|
||
} else {
|
||
console.log(`✅ 품목코드 초기화 (수동 입력 필요): ${field} (기존값: ${originalValue})`);
|
||
}
|
||
|
||
resetFieldName = field;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 작성자 정보를 현재 사용자로 변경
|
||
const writerFields = ["writer", "creator", "reg_user", "regUser", "created_by", "createdBy"];
|
||
writerFields.forEach((field) => {
|
||
if (copiedData[field] !== undefined && context.userId) {
|
||
copiedData[field] = context.userId;
|
||
console.log(`👤 작성자 변경: ${field} = ${context.userId}`);
|
||
}
|
||
});
|
||
|
||
if (resetFieldName) {
|
||
toast.success("복사본이 생성되었습니다. 품목코드는 저장 시 자동으로 생성됩니다.");
|
||
} else {
|
||
console.warn("⚠️ 품목코드 필드를 찾을 수 없습니다. 전체 데이터를 복사합니다.");
|
||
console.warn("⚠️ 사용 가능한 필드:", Object.keys(copiedData));
|
||
toast.info("복사본이 생성됩니다.");
|
||
}
|
||
|
||
console.log("📋 복사된 데이터:", copiedData);
|
||
await this.openCopyForm(config, copiedData, context);
|
||
} else {
|
||
// 다중 항목 복사 - 현재는 단일 복사만 지원
|
||
toast.error("현재 단일 항목 복사만 지원됩니다. 하나의 항목만 선택해주세요.");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error("❌ 복사 액션 실행 중 오류:", error);
|
||
toast.error(`복사 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 복사 폼 열기 (단일 항목)
|
||
*/
|
||
private static async openCopyForm(
|
||
config: ButtonActionConfig,
|
||
rowData: any,
|
||
context: ButtonActionContext,
|
||
): Promise<void> {
|
||
try {
|
||
const editMode = config.editMode || "modal";
|
||
console.log("📋 openCopyForm 실행:", { editMode, targetScreenId: config.targetScreenId });
|
||
|
||
switch (editMode) {
|
||
case "modal":
|
||
// 모달로 복사 폼 열기 (편집 모달 재사용, INSERT 모드로)
|
||
console.log("📋 모달로 복사 폼 열기 (INSERT 모드)");
|
||
await this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
|
||
break;
|
||
|
||
case "navigate":
|
||
// 새 페이지로 이동
|
||
console.log("📋 새 페이지로 복사 화면 이동");
|
||
this.navigateToCopyScreen(config, rowData, context);
|
||
break;
|
||
|
||
default:
|
||
// 기본값: 모달
|
||
console.log("📋 기본 모달로 복사 폼 열기 (INSERT 모드)");
|
||
this.openEditModal(config, rowData, context, true); // 🆕 isCreateMode: true
|
||
}
|
||
} catch (error: any) {
|
||
console.error("❌ openCopyForm 실행 중 오류:", error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 복사 화면으로 네비게이션
|
||
*/
|
||
private static navigateToCopyScreen(config: ButtonActionConfig, rowData: any, context: ButtonActionContext): void {
|
||
const copyUrl = `/screens/${config.targetScreenId}?mode=copy`;
|
||
console.log("🔄 복사 화면으로 이동:", copyUrl);
|
||
|
||
// 복사할 데이터를 sessionStorage에 저장
|
||
sessionStorage.setItem("copyData", JSON.stringify(rowData));
|
||
|
||
window.location.href = copyUrl;
|
||
}
|
||
|
||
/**
|
||
* 닫기 액션 처리
|
||
*/
|
||
private static handleClose(config: ButtonActionConfig, context: ButtonActionContext): boolean {
|
||
console.log("닫기 액션 실행:", context);
|
||
context.onClose?.();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 제어 전용 액션 처리 (조건 체크만 수행)
|
||
*/
|
||
private static async handleControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<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":
|
||
// 폼 + 테이블 선택 (데이터 병합)
|
||
// 🔥 각 selectedRowsData 항목에 formData를 병합하여 전달
|
||
// 이렇게 해야 메일 발송 시 부모 데이터(상품명 등)와 폼 데이터(수신자 등)가 모두 변수로 사용 가능
|
||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||
sourceData = context.selectedRowsData.map((row: any) => ({
|
||
...row,
|
||
...(context.formData || {}),
|
||
}));
|
||
console.log("🔀 폼 + 테이블 선택 데이터 병합:", {
|
||
dataCount: sourceData.length,
|
||
sourceData,
|
||
});
|
||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||
sourceData = [context.formData];
|
||
console.log("🔀 폼 데이터만 사용 (선택된 행 없음):", sourceData);
|
||
}
|
||
break;
|
||
|
||
default:
|
||
// 자동 판단 (설정이 없는 경우)
|
||
if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||
sourceData = context.flowSelectedData;
|
||
dataSourceType = "flow-selection";
|
||
console.log("🌊 [자동] 플로우 선택 데이터 사용");
|
||
} else if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||
// 🔥 selectedRowsData가 있으면 formData도 함께 병합
|
||
// 모달에서 부모 데이터(selectedRowsData)와 폼 입력(formData)을 모두 사용할 수 있도록
|
||
if (context.formData && Object.keys(context.formData).length > 0) {
|
||
sourceData = context.selectedRowsData.map((row: any) => ({
|
||
...row,
|
||
...context.formData,
|
||
}));
|
||
dataSourceType = "both";
|
||
console.log("📊 [자동] 테이블 선택 + 폼 데이터 병합 사용:", {
|
||
rowCount: context.selectedRowsData.length,
|
||
formDataKeys: Object.keys(context.formData),
|
||
});
|
||
} else {
|
||
sourceData = context.selectedRowsData;
|
||
dataSourceType = "table-selection";
|
||
console.log("📊 [자동] 테이블 선택 데이터 사용");
|
||
}
|
||
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||
sourceData = [context.formData];
|
||
dataSourceType = "form";
|
||
console.log("📝 [자동] 폼 데이터 사용");
|
||
}
|
||
break;
|
||
}
|
||
|
||
console.log("📦 최종 전달 데이터:", {
|
||
dataSourceType,
|
||
sourceDataCount: sourceData ? (Array.isArray(sourceData) ? sourceData.length : 1) : 0,
|
||
sourceData,
|
||
});
|
||
|
||
const result = await executeNodeFlow(flowId, {
|
||
dataSourceType,
|
||
sourceData,
|
||
context,
|
||
});
|
||
|
||
if (result.success) {
|
||
console.log("✅ 노드 플로우 실행 완료:", result);
|
||
toast.success("플로우 실행이 완료되었습니다.");
|
||
|
||
// 플로우 새로고침 (플로우 위젯용)
|
||
if (context.onFlowRefresh) {
|
||
console.log("🔄 플로우 새로고침 호출");
|
||
context.onFlowRefresh();
|
||
}
|
||
|
||
// 테이블 새로고침 (일반 테이블용)
|
||
if (context.onRefresh) {
|
||
console.log("🔄 테이블 새로고침 호출");
|
||
context.onRefresh();
|
||
}
|
||
|
||
return true;
|
||
} else {
|
||
console.error("❌ 노드 플로우 실행 실패:", result);
|
||
toast.error(config.errorMessage || result.message || "플로우 실행 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 노드 플로우 실행 오류:", error);
|
||
toast.error("플로우 실행 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
} else if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||
console.log("🔗 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
||
|
||
// 🔥 table-selection 모드일 때 선택된 행 데이터를 formData에 병합
|
||
let mergedFormData = { ...context.formData } || {};
|
||
|
||
if (
|
||
controlDataSource === "table-selection" &&
|
||
context.selectedRowsData &&
|
||
context.selectedRowsData.length > 0
|
||
) {
|
||
// 선택된 첫 번째 행의 데이터를 formData에 병합
|
||
const selectedRowData = context.selectedRowsData[0];
|
||
mergedFormData = { ...mergedFormData, ...selectedRowData };
|
||
console.log("🔄 선택된 행 데이터를 formData에 병합:", {
|
||
originalFormData: context.formData,
|
||
selectedRowData,
|
||
mergedFormData,
|
||
});
|
||
}
|
||
|
||
// 새로운 ImprovedButtonActionExecutor 사용
|
||
const buttonConfig = {
|
||
actionType: config.type,
|
||
dataflowConfig: config.dataflowConfig,
|
||
enableDataflowControl: true, // 관계 기반 제어가 설정된 경우 활성화
|
||
};
|
||
|
||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(buttonConfig, mergedFormData, {
|
||
buttonId: context.buttonId || "unknown",
|
||
screenId: context.screenId || "unknown",
|
||
userId: context.userId || "unknown",
|
||
companyCode: context.companyCode || "*",
|
||
startTime: Date.now(),
|
||
contextData: context,
|
||
});
|
||
|
||
if (executionResult.success) {
|
||
console.log("✅ 관계 실행 완료:", executionResult);
|
||
toast.success(config.successMessage || "관계 실행이 완료되었습니다.");
|
||
|
||
// 새로고침이 필요한 경우
|
||
if (context.onRefresh) {
|
||
context.onRefresh();
|
||
}
|
||
|
||
return true;
|
||
} else {
|
||
console.error("❌ 관계 실행 실패:", executionResult);
|
||
toast.error(config.errorMessage || "관계 실행 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
} else {
|
||
// 제어 없음 - 성공 처리
|
||
console.log("⚡ 제어 없음 - 버튼 액션만 실행");
|
||
|
||
// 새로고침이 필요한 경우
|
||
if (context.onRefresh) {
|
||
context.onRefresh();
|
||
}
|
||
|
||
return true;
|
||
}
|
||
} catch (error) {
|
||
console.error("제어 조건 검증 중 오류:", error);
|
||
toast.error("제어 조건 검증 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 저장 후 제어 실행 (After Timing)
|
||
* EditModal 등 외부에서도 호출 가능하도록 public으로 변경
|
||
* 다중 제어 순차 실행 지원
|
||
*/
|
||
public static async executeAfterSaveControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<void> {
|
||
console.log("🎯 저장 후 제어 실행:", {
|
||
enableDataflowControl: config.enableDataflowControl,
|
||
dataflowConfig: config.dataflowConfig,
|
||
dataflowTiming: config.dataflowTiming,
|
||
});
|
||
|
||
// 제어 데이터 소스 결정
|
||
let controlDataSource = config.dataflowConfig?.controlDataSource;
|
||
if (!controlDataSource) {
|
||
controlDataSource = "form"; // 저장 후에는 기본적으로 form 데이터 사용
|
||
}
|
||
|
||
const extendedContext: ExtendedControlContext = {
|
||
formData: context.formData || {},
|
||
selectedRows: context.selectedRows || [],
|
||
selectedRowsData: context.selectedRowsData || [],
|
||
controlDataSource,
|
||
};
|
||
|
||
// 🔥 다중 제어 지원 (flowControls 배열)
|
||
const flowControls = config.dataflowConfig?.flowControls || [];
|
||
if (flowControls.length > 0) {
|
||
console.log(`🎯 다중 제어 순차 실행 시작: ${flowControls.length}개`);
|
||
|
||
// 순서대로 정렬
|
||
const sortedControls = [...flowControls].sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
||
|
||
// 노드 플로우 실행 API
|
||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||
|
||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||
// 우선순위: selectedRowsData > savedData > formData
|
||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||
// - savedData: 저장 API 응답 데이터
|
||
// - formData: 폼에 입력된 데이터
|
||
let sourceData: any[];
|
||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||
sourceData = context.selectedRowsData;
|
||
console.log("📦 [다중제어] selectedRowsData 사용:", sourceData.length, "건");
|
||
} else {
|
||
const savedData = context.savedData || context.formData || {};
|
||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||
console.log("📦 [다중제어] savedData/formData 사용:", sourceData.length, "건");
|
||
}
|
||
|
||
let allSuccess = true;
|
||
const results: Array<{ flowId: number; flowName: string; success: boolean; message?: string }> = [];
|
||
|
||
for (let i = 0; i < sortedControls.length; i++) {
|
||
const control = sortedControls[i];
|
||
|
||
// 유효하지 않은 flowId 스킵
|
||
if (!control.flowId || control.flowId <= 0) {
|
||
console.warn(`⚠️ [${i + 1}/${sortedControls.length}] 유효하지 않은 flowId, 스킵:`, control);
|
||
continue;
|
||
}
|
||
|
||
// executionTiming 체크 (after만 실행)
|
||
if (control.executionTiming && control.executionTiming !== "after") {
|
||
console.log(
|
||
`⏭️ [${i + 1}/${sortedControls.length}] executionTiming이 'after'가 아님, 스킵:`,
|
||
control.executionTiming,
|
||
);
|
||
continue;
|
||
}
|
||
|
||
console.log(
|
||
`\n📍 [${i + 1}/${sortedControls.length}] 제어 실행: ${control.flowName} (flowId: ${control.flowId})`,
|
||
);
|
||
|
||
try {
|
||
const result = await executeNodeFlow(control.flowId, {
|
||
dataSourceType: controlDataSource,
|
||
sourceData,
|
||
context: extendedContext,
|
||
});
|
||
|
||
results.push({
|
||
flowId: control.flowId,
|
||
flowName: control.flowName,
|
||
success: result.success,
|
||
message: result.message,
|
||
});
|
||
|
||
if (result.success) {
|
||
console.log(`✅ [${i + 1}/${sortedControls.length}] 제어 성공: ${control.flowName}`);
|
||
} else {
|
||
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실패: ${control.flowName} - ${result.message}`);
|
||
allSuccess = false;
|
||
// 이전 제어 실패 시 다음 제어 실행 중단
|
||
console.warn("⚠️ 이전 제어 실패로 인해 나머지 제어 실행 중단");
|
||
break;
|
||
}
|
||
} catch (error: any) {
|
||
console.error(`❌ [${i + 1}/${sortedControls.length}] 제어 실행 오류: ${control.flowName}`, error);
|
||
results.push({
|
||
flowId: control.flowId,
|
||
flowName: control.flowName,
|
||
success: false,
|
||
message: error.message,
|
||
});
|
||
allSuccess = false;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 결과 요약
|
||
const successCount = results.filter((r) => r.success).length;
|
||
const failCount = results.filter((r) => !r.success).length;
|
||
console.log("\n📊 다중 제어 실행 완료:", {
|
||
total: sortedControls.length,
|
||
executed: results.length,
|
||
success: successCount,
|
||
failed: failCount,
|
||
});
|
||
|
||
if (allSuccess) {
|
||
toast.success(`${successCount}개 제어 실행 완료`);
|
||
} else {
|
||
toast.error(`제어 실행 중 오류 발생 (${successCount}/${results.length} 성공)`);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// 🔥 기존 단일 제어 실행 (하위 호환성)
|
||
// dataflowTiming이 'after'가 아니면 실행하지 않음
|
||
if (config.dataflowTiming && config.dataflowTiming !== "after") {
|
||
console.log("⏭️ dataflowTiming이 'after'가 아니므로 제어 실행 건너뜀:", config.dataflowTiming);
|
||
return;
|
||
}
|
||
|
||
// 노드 플로우 방식 실행 (flowConfig가 있는 경우)
|
||
const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId;
|
||
if (hasFlowConfig) {
|
||
// executionTiming 체크
|
||
const flowTiming = config.dataflowConfig.flowConfig.executionTiming;
|
||
if (flowTiming && flowTiming !== "after") {
|
||
console.log("⏭️ flowConfig.executionTiming이 'after'가 아니므로 제어 실행 건너뜀:", flowTiming);
|
||
return;
|
||
}
|
||
|
||
console.log("🎯 저장 후 노드 플로우 실행:", config.dataflowConfig.flowConfig);
|
||
|
||
const { flowId } = config.dataflowConfig.flowConfig;
|
||
|
||
try {
|
||
// 노드 플로우 실행 API 호출
|
||
const { executeNodeFlow } = await import("@/lib/api/nodeFlows");
|
||
|
||
// 데이터 소스 준비: context-data 모드는 배열을 기대함
|
||
// 우선순위: selectedRowsData > savedData > formData
|
||
// - selectedRowsData: 테이블 섹션에서 저장된 하위 항목들 (item_code, inbound_qty 등 포함)
|
||
// - savedData: 저장 API 응답 데이터
|
||
// - formData: 폼에 입력된 데이터
|
||
let sourceData: any[];
|
||
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||
sourceData = context.selectedRowsData;
|
||
console.log("📦 [단일제어] selectedRowsData 사용:", sourceData.length, "건");
|
||
} else {
|
||
const savedData = context.savedData || context.formData || {};
|
||
sourceData = Array.isArray(savedData) ? savedData : [savedData];
|
||
console.log("📦 [단일제어] savedData/formData 사용:", sourceData.length, "건");
|
||
}
|
||
|
||
// repeat-screen-modal 데이터가 있으면 병합
|
||
const repeatScreenModalKeys = Object.keys(context.formData || {}).filter((key) =>
|
||
key.startsWith("_repeatScreenModal_"),
|
||
);
|
||
if (repeatScreenModalKeys.length > 0) {
|
||
console.log("📦 repeat-screen-modal 데이터 발견:", repeatScreenModalKeys);
|
||
}
|
||
|
||
console.log("📦 노드 플로우에 전달할 데이터:", {
|
||
flowId,
|
||
dataSourceType: controlDataSource,
|
||
sourceDataCount: sourceData.length,
|
||
sourceDataSample: sourceData[0],
|
||
});
|
||
|
||
const result = await executeNodeFlow(flowId, {
|
||
dataSourceType: controlDataSource,
|
||
sourceData,
|
||
context: extendedContext,
|
||
});
|
||
|
||
if (result.success) {
|
||
console.log("✅ 저장 후 노드 플로우 실행 완료:", result);
|
||
toast.success("제어 로직 실행이 완료되었습니다.");
|
||
} else {
|
||
console.error("❌ 저장 후 노드 플로우 실행 실패:", result);
|
||
toast.error("저장은 완료되었으나 제어 실행 중 오류가 발생했습니다.");
|
||
}
|
||
} catch (error: any) {
|
||
console.error("❌ 저장 후 노드 플로우 실행 오류:", error);
|
||
toast.error(`제어 실행 오류: ${error.message || "알 수 없는 오류"}`);
|
||
}
|
||
|
||
return; // 노드 플로우 실행 후 종료
|
||
}
|
||
|
||
// 관계 기반 제어 실행
|
||
if (config.dataflowConfig?.controlMode === "relationship" && config.dataflowConfig?.relationshipConfig) {
|
||
console.log("🔗 저장 후 관계 기반 제어 실행:", config.dataflowConfig.relationshipConfig);
|
||
|
||
const buttonConfig = {
|
||
actionType: config.type,
|
||
dataflowConfig: config.dataflowConfig,
|
||
enableDataflowControl: true,
|
||
};
|
||
|
||
const executionResult = await ImprovedButtonActionExecutor.executeButtonAction(
|
||
buttonConfig,
|
||
context.formData || {},
|
||
{
|
||
buttonId: context.buttonId || "unknown",
|
||
screenId: context.screenId || "unknown",
|
||
userId: context.userId || "unknown",
|
||
companyCode: context.companyCode || "*",
|
||
startTime: Date.now(),
|
||
contextData: context,
|
||
},
|
||
);
|
||
|
||
if (executionResult.success) {
|
||
console.log("✅ 저장 후 제어 실행 완료:", executionResult);
|
||
// 성공 토스트는 save 액션에서 이미 표시했으므로 추가로 표시하지 않음
|
||
} else {
|
||
console.error("❌ 저장 후 제어 실행 실패:", executionResult);
|
||
toast.error("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 관계도에서 가져온 액션들을 실행
|
||
*/
|
||
private static async executeRelationshipActions(actions: any[], context: ButtonActionContext): Promise<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) {
|
||
const layoutData = layoutResponse.data.data;
|
||
|
||
// components가 문자열이면 파싱
|
||
if (typeof layoutData.components === "string") {
|
||
layoutData.components = JSON.parse(layoutData.components);
|
||
}
|
||
|
||
// 테이블 리스트 컴포넌트 찾기
|
||
const findTableListComponent = (components: any[]): any => {
|
||
if (!Array.isArray(components)) return null;
|
||
|
||
for (const comp of components) {
|
||
// componentType이 'table-list'인지 확인
|
||
const isTableList = comp.componentType === "table-list";
|
||
|
||
// componentConfig 안에서 테이블명 확인
|
||
const matchesTable =
|
||
comp.componentConfig?.selectedTable === context.tableName ||
|
||
comp.componentConfig?.tableName === context.tableName;
|
||
|
||
if (isTableList && matchesTable) {
|
||
return comp;
|
||
}
|
||
if (comp.children && comp.children.length > 0) {
|
||
const found = findTableListComponent(comp.children);
|
||
if (found) return found;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const tableListComponent = findTableListComponent(layoutData.components || []);
|
||
|
||
if (tableListComponent && tableListComponent.componentConfig?.columns) {
|
||
const columns = tableListComponent.componentConfig.columns;
|
||
|
||
// visible이 true인 컬럼만 추출
|
||
visibleColumns = columns.filter((col: any) => col.visible !== false).map((col: any) => col.columnName);
|
||
|
||
// 🎯 column_labels 테이블에서 실제 라벨 가져오기
|
||
try {
|
||
const columnsResponse = await apiClient.get(`/table-management/tables/${context.tableName}/columns`, {
|
||
params: { page: 1, size: 9999 },
|
||
});
|
||
|
||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||
let columnData = columnsResponse.data.data;
|
||
|
||
// data가 객체이고 columns 필드가 있으면 추출
|
||
if (columnData.columns && Array.isArray(columnData.columns)) {
|
||
columnData = columnData.columns;
|
||
}
|
||
|
||
if (Array.isArray(columnData)) {
|
||
columnLabels = {};
|
||
|
||
// API에서 가져온 라벨로 매핑
|
||
columnData.forEach((colData: any) => {
|
||
const colName = colData.column_name || colData.columnName;
|
||
// 우선순위: column_label > label > displayName > columnName
|
||
const labelValue = colData.column_label || colData.label || colData.displayName || colName;
|
||
if (colName && labelValue) {
|
||
columnLabels![colName] = labelValue;
|
||
}
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 실패 시 컴포넌트 설정의 displayName 사용
|
||
columnLabels = {};
|
||
columns.forEach((col: any) => {
|
||
if (col.columnName) {
|
||
columnLabels![col.columnName] = col.displayName || col.label || col.columnName;
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
console.warn("⚠️ 화면 레이아웃에서 테이블 리스트 컴포넌트를 찾을 수 없습니다.");
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("❌ 화면 레이아웃 조회 실패:", error);
|
||
}
|
||
|
||
// 🎨 카테고리 값들 조회 (한 번만)
|
||
const categoryMap: Record<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");
|
||
|
||
toast.loading("영향받는 데이터 검색 중...", { duration: Infinity });
|
||
|
||
const previewResponse = await apiClient.post("/code-merge/preview-by-value", {
|
||
oldValue,
|
||
});
|
||
|
||
toast.dismiss();
|
||
|
||
if (previewResponse.data.success) {
|
||
const preview = previewResponse.data.data;
|
||
const totalRows = preview.totalAffectedRows;
|
||
|
||
// 상세 정보 생성
|
||
const detailList = preview.preview
|
||
.map((p: any) => ` - ${p.tableName}.${p.columnName}: ${p.affectedRows}건`)
|
||
.join("\n");
|
||
|
||
const confirmMerge = confirm(
|
||
"코드 병합 확인\n\n" +
|
||
`${oldValue} → ${newValue}\n\n` +
|
||
"영향받는 데이터:\n" +
|
||
`- 테이블/컬럼 수: ${preview.preview.length}개\n` +
|
||
`- 총 행 수: ${totalRows}개\n\n` +
|
||
(preview.preview.length <= 10 ? `상세:\n${detailList}\n\n` : "") +
|
||
"모든 테이블에서 해당 값이 변경됩니다.\n\n" +
|
||
"계속하시겠습니까?",
|
||
);
|
||
|
||
if (!confirmMerge) {
|
||
return false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 병합 실행 (값 기반 - 모든 테이블의 모든 컬럼)
|
||
toast.loading("코드 병합 중...", { duration: Infinity });
|
||
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
const response = await apiClient.post("/code-merge/merge-by-value", {
|
||
oldValue,
|
||
newValue,
|
||
});
|
||
|
||
toast.dismiss();
|
||
|
||
if (response.data.success) {
|
||
const data = response.data.data;
|
||
|
||
// 변경된 테이블/컬럼 목록 생성
|
||
const changedList = data.affectedData
|
||
.map((d: any) => `${d.tableName}.${d.columnName}: ${d.rowsUpdated}건`)
|
||
.join(", ");
|
||
|
||
toast.success(
|
||
`코드 병합 완료! ${data.affectedData.length}개 테이블/컬럼, ${data.totalRowsUpdated}개 행 업데이트`,
|
||
);
|
||
|
||
console.log("코드 병합 결과:", data.affectedData);
|
||
|
||
// 화면 새로고침
|
||
context.onRefresh?.();
|
||
context.onFlowRefresh?.();
|
||
|
||
return true;
|
||
} else {
|
||
toast.error(response.data.message || "코드 병합에 실패했습니다.");
|
||
return false;
|
||
}
|
||
} catch (error: any) {
|
||
console.error("❌ 코드 병합 실패:", error);
|
||
toast.dismiss();
|
||
toast.error(error.response?.data?.message || "코드 병합 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 🆕 연속 위치 추적 상태 저장 (전역)
|
||
private static trackingIntervalId: NodeJS.Timeout | null = null;
|
||
private static currentTripId: string | null = null;
|
||
private static trackingContext: ButtonActionContext | null = null;
|
||
private static trackingConfig: ButtonActionConfig | null = null;
|
||
|
||
/**
|
||
* 연속 위치 추적 시작
|
||
*/
|
||
private static async handleTrackingStart(config: ButtonActionConfig, context: ButtonActionContext): Promise<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);
|
||
|
||
// 🆕 출발지/도착지도 vehicles 테이블에 저장
|
||
if (departure) {
|
||
try {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: statusTableName,
|
||
keyField: keyField,
|
||
keyValue: keyValue,
|
||
updateField: "departure",
|
||
updateValue: departure,
|
||
});
|
||
console.log("✅ 출발지 저장 완료:", departure);
|
||
} catch {
|
||
// 컬럼이 없으면 무시
|
||
}
|
||
}
|
||
|
||
if (arrival) {
|
||
try {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: statusTableName,
|
||
keyField: keyField,
|
||
keyValue: keyValue,
|
||
updateField: "arrival",
|
||
updateValue: arrival,
|
||
});
|
||
console.log("✅ 도착지 저장 완료:", arrival);
|
||
} catch {
|
||
// 컬럼이 없으면 무시
|
||
}
|
||
}
|
||
}
|
||
} catch (statusError) {
|
||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||
}
|
||
}
|
||
|
||
// 첫 번째 위치 저장
|
||
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
|
||
|
||
// 주기적 위치 저장 시작
|
||
const interval = config.trackingInterval || 10000; // 기본 10초
|
||
this.trackingIntervalId = setInterval(async () => {
|
||
await this.saveLocationToHistory(tripId, departure, arrival, departureName, destinationName, vehicleId);
|
||
}, interval);
|
||
|
||
toast.success(config.successMessage || `위치 추적이 시작되었습니다. (${interval / 1000}초 간격)`);
|
||
|
||
// 추적 시작 이벤트 발생 (UI 업데이트용)
|
||
window.dispatchEvent(
|
||
new CustomEvent("trackingStarted", {
|
||
detail: { tripId, interval },
|
||
}),
|
||
);
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error("❌ 위치 추적 시작 실패:", error);
|
||
toast.error(config.errorMessage || "위치 추적 시작 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 연속 위치 추적 종료
|
||
*/
|
||
private static async handleTrackingStop(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
try {
|
||
console.log("🛑 [handleTrackingStop] 위치 추적 종료:", { config, context });
|
||
|
||
// 추적 중인지 확인 (새로고침 후에도 DB 상태 기반 종료 가능하도록 수정)
|
||
const isTrackingActive = !!this.trackingIntervalId;
|
||
|
||
if (!isTrackingActive) {
|
||
// 추적 중이 아니어도 DB 상태 변경은 진행 (새로고침 후 종료 지원)
|
||
console.log("⚠️ [handleTrackingStop] trackingIntervalId 없음 - DB 상태 기반 종료 진행");
|
||
} else {
|
||
// 타이머 정리 (추적 중인 경우에만)
|
||
clearInterval(this.trackingIntervalId);
|
||
this.trackingIntervalId = null;
|
||
}
|
||
|
||
const tripId = this.currentTripId;
|
||
|
||
// 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용)
|
||
let dbDeparture: string | null = null;
|
||
let dbArrival: string | null = null;
|
||
let dbVehicleId: string | null = null;
|
||
|
||
const userId = context.userId || this.trackingUserId;
|
||
if (userId) {
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
const statusTableName =
|
||
config.trackingStatusTableName ||
|
||
this.trackingConfig?.trackingStatusTableName ||
|
||
context.tableName ||
|
||
"vehicles";
|
||
const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id";
|
||
|
||
// DB에서 현재 차량 정보 조회
|
||
const vehicleResponse = await apiClient.post(`/table-management/tables/${statusTableName}/data`, {
|
||
page: 1,
|
||
size: 1,
|
||
search: { [keyField]: userId },
|
||
autoFilter: true,
|
||
});
|
||
|
||
const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0];
|
||
if (vehicleData) {
|
||
dbDeparture = vehicleData.departure || null;
|
||
dbArrival = vehicleData.arrival || null;
|
||
dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null;
|
||
console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId });
|
||
}
|
||
} catch (dbError) {
|
||
console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError);
|
||
}
|
||
}
|
||
|
||
// 마지막 위치 저장 (추적 중이었던 경우에만)
|
||
if (isTrackingActive) {
|
||
// DB 값 우선, 없으면 formData 사용
|
||
const departure =
|
||
dbDeparture ||
|
||
this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] ||
|
||
null;
|
||
const arrival =
|
||
dbArrival || this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null;
|
||
const departureName = this.trackingContext?.formData?.["departure_name"] || null;
|
||
const destinationName = this.trackingContext?.formData?.["destination_name"] || null;
|
||
const vehicleId =
|
||
dbVehicleId ||
|
||
this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] ||
|
||
null;
|
||
|
||
await this.saveLocationToHistory(
|
||
tripId,
|
||
departure,
|
||
arrival,
|
||
departureName,
|
||
destinationName,
|
||
vehicleId,
|
||
"completed",
|
||
);
|
||
}
|
||
|
||
// 🆕 거리/시간 계산 및 저장 (추적 중이었던 경우에만)
|
||
if (isTrackingActive && tripId) {
|
||
try {
|
||
const tripStats = await this.calculateTripStats(tripId);
|
||
console.log("📊 운행 통계:", tripStats);
|
||
|
||
// 운행 통계를 두 테이블에 저장
|
||
if (tripStats) {
|
||
const distanceMeters = Math.round(tripStats.totalDistanceKm * 1000); // km → m
|
||
const timeMinutes = tripStats.totalTimeMinutes;
|
||
const userId = this.trackingUserId || context.userId;
|
||
|
||
console.log("💾 운행 통계 DB 저장 시도:", {
|
||
tripId,
|
||
userId,
|
||
distanceMeters,
|
||
timeMinutes,
|
||
startTime: tripStats.startTime,
|
||
endTime: tripStats.endTime,
|
||
});
|
||
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
// 1️⃣ vehicle_location_history 마지막 레코드에 통계 저장 (이력용)
|
||
try {
|
||
const lastRecordResponse = await apiClient.post(
|
||
"/table-management/tables/vehicle_location_history/data",
|
||
{
|
||
page: 1,
|
||
size: 1,
|
||
search: { trip_id: tripId },
|
||
sortBy: "recorded_at",
|
||
sortOrder: "desc",
|
||
autoFilter: true,
|
||
},
|
||
);
|
||
|
||
const lastRecordData = lastRecordResponse.data?.data?.data || lastRecordResponse.data?.data?.rows || [];
|
||
if (lastRecordData.length > 0) {
|
||
const lastRecordId = lastRecordData[0].id;
|
||
console.log("📍 마지막 레코드 ID:", lastRecordId);
|
||
|
||
const historyUpdates = [
|
||
{ field: "trip_distance", value: distanceMeters },
|
||
{ field: "trip_time", value: timeMinutes },
|
||
{ field: "trip_start", value: tripStats.startTime },
|
||
{ field: "trip_end", value: tripStats.endTime },
|
||
];
|
||
|
||
for (const update of historyUpdates) {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: "vehicle_location_history",
|
||
keyField: "id",
|
||
keyValue: lastRecordId,
|
||
updateField: update.field,
|
||
updateValue: update.value,
|
||
});
|
||
}
|
||
console.log("✅ vehicle_location_history 통계 저장 완료");
|
||
} else {
|
||
console.warn("⚠️ trip_id에 해당하는 레코드를 찾을 수 없음:", tripId);
|
||
}
|
||
} catch (historyError) {
|
||
console.warn("⚠️ vehicle_location_history 저장 실패:", historyError);
|
||
}
|
||
|
||
// 2️⃣ vehicles 테이블에도 마지막 운행 통계 업데이트 (최신 정보용)
|
||
if (userId) {
|
||
try {
|
||
const vehicleUpdates = [
|
||
{ field: "last_trip_distance", value: distanceMeters },
|
||
{ field: "last_trip_time", value: timeMinutes },
|
||
{ field: "last_trip_start", value: tripStats.startTime },
|
||
{ field: "last_trip_end", value: tripStats.endTime },
|
||
];
|
||
|
||
for (const update of vehicleUpdates) {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: "vehicles",
|
||
keyField: "user_id",
|
||
keyValue: userId,
|
||
updateField: update.field,
|
||
updateValue: update.value,
|
||
});
|
||
}
|
||
console.log("✅ vehicles 테이블 통계 업데이트 완료");
|
||
} catch (vehicleError) {
|
||
console.warn("⚠️ vehicles 테이블 저장 실패:", vehicleError);
|
||
}
|
||
}
|
||
|
||
// 이벤트로 통계 전달 (UI에서 표시용)
|
||
window.dispatchEvent(
|
||
new CustomEvent("tripCompleted", {
|
||
detail: {
|
||
tripId,
|
||
totalDistanceKm: tripStats.totalDistanceKm,
|
||
totalTimeMinutes: tripStats.totalTimeMinutes,
|
||
startTime: tripStats.startTime,
|
||
endTime: tripStats.endTime,
|
||
},
|
||
}),
|
||
);
|
||
|
||
toast.success(
|
||
`운행 종료! 총 ${tripStats.totalDistanceKm.toFixed(1)}km, ${tripStats.totalTimeMinutes}분 소요`,
|
||
);
|
||
}
|
||
} catch (statsError) {
|
||
console.warn("⚠️ 운행 통계 계산 실패:", statsError);
|
||
}
|
||
}
|
||
|
||
// 상태 변경 (vehicles 테이블 등) - 새로고침 후에도 동작하도록 config 우선 사용
|
||
const effectiveConfig = config.trackingStatusOnStop ? config : this.trackingConfig || config;
|
||
const effectiveContext = context.userId ? context : this.trackingContext || context;
|
||
|
||
if (effectiveConfig?.trackingStatusOnStop && effectiveConfig?.trackingStatusField && effectiveContext) {
|
||
try {
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
const statusTableName = effectiveConfig.trackingStatusTableName || effectiveContext.tableName;
|
||
const keyField = effectiveConfig.trackingStatusKeyField || "user_id";
|
||
const keyValue = resolveSpecialKeyword(
|
||
effectiveConfig.trackingStatusKeySourceField || "__userId__",
|
||
effectiveContext,
|
||
);
|
||
|
||
if (keyValue) {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: statusTableName,
|
||
keyField: keyField,
|
||
keyValue: keyValue,
|
||
updateField: effectiveConfig.trackingStatusField,
|
||
updateValue: effectiveConfig.trackingStatusOnStop,
|
||
});
|
||
console.log("✅ 상태 변경 완료:", effectiveConfig.trackingStatusOnStop);
|
||
|
||
// 🆕 운행 종료 시 vehicles 테이블의 출발지/도착지/위도/경도를 null로 초기화
|
||
const fieldsToReset = ["departure", "arrival", "latitude", "longitude"];
|
||
for (const field of fieldsToReset) {
|
||
try {
|
||
await apiClient.put("/dynamic-form/update-field", {
|
||
tableName: statusTableName,
|
||
keyField: keyField,
|
||
keyValue: keyValue,
|
||
updateField: field,
|
||
updateValue: null,
|
||
});
|
||
} catch {
|
||
// 컬럼이 없으면 무시
|
||
}
|
||
}
|
||
console.log("✅ 출발지/도착지/위도/경도 초기화 완료");
|
||
}
|
||
} catch (statusError) {
|
||
console.warn("⚠️ 상태 변경 실패:", statusError);
|
||
}
|
||
}
|
||
|
||
// 컨텍스트 정리
|
||
this.currentTripId = null;
|
||
this.trackingContext = null;
|
||
this.trackingConfig = null;
|
||
|
||
toast.success(config.successMessage || "위치 추적이 종료되었습니다.");
|
||
|
||
// 추적 종료 이벤트 발생 (UI 업데이트용)
|
||
window.dispatchEvent(
|
||
new CustomEvent("trackingStopped", {
|
||
detail: { tripId },
|
||
}),
|
||
);
|
||
|
||
// 화면 새로고침
|
||
context.onRefresh?.();
|
||
|
||
return true;
|
||
} catch (error: any) {
|
||
console.error("❌ 위치 추적 종료 실패:", error);
|
||
toast.error(config.errorMessage || "위치 추적 종료 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 운행 통계 계산 (거리, 시간)
|
||
*/
|
||
private static async calculateTripStats(tripId: string): Promise<{
|
||
totalDistanceKm: number;
|
||
totalTimeMinutes: number;
|
||
startTime: string | null;
|
||
endTime: string | null;
|
||
} | null> {
|
||
try {
|
||
// vehicle_location_history에서 해당 trip의 모든 위치 조회
|
||
const { apiClient } = await import("@/lib/api/client");
|
||
|
||
const response = await apiClient.post("/table-management/tables/vehicle_location_history/data", {
|
||
page: 1,
|
||
size: 10000,
|
||
search: { trip_id: tripId },
|
||
sortBy: "recorded_at",
|
||
sortOrder: "asc",
|
||
});
|
||
|
||
if (!response.data?.success) {
|
||
console.log("📊 통계 계산: API 응답 실패");
|
||
return null;
|
||
}
|
||
|
||
// 응답 형식: data.data.data 또는 data.data.rows
|
||
const rows = response.data?.data?.data || response.data?.data?.rows || [];
|
||
|
||
if (!rows.length) {
|
||
console.log("📊 통계 계산: 데이터 없음");
|
||
return null;
|
||
}
|
||
|
||
const locations = rows;
|
||
console.log(`📊 통계 계산: ${locations.length}개 위치 데이터`);
|
||
|
||
// 시간 계산
|
||
const startTime = locations[0].recorded_at;
|
||
const endTime = locations[locations.length - 1].recorded_at;
|
||
const totalTimeMs = new Date(endTime).getTime() - new Date(startTime).getTime();
|
||
const totalTimeMinutes = Math.round(totalTimeMs / 60000);
|
||
|
||
// 거리 계산 (Haversine 공식)
|
||
let totalDistanceM = 0;
|
||
for (let i = 1; i < locations.length; i++) {
|
||
const prev = locations[i - 1];
|
||
const curr = locations[i];
|
||
|
||
if (prev.latitude && prev.longitude && curr.latitude && curr.longitude) {
|
||
const distance = this.calculateDistance(
|
||
parseFloat(prev.latitude),
|
||
parseFloat(prev.longitude),
|
||
parseFloat(curr.latitude),
|
||
parseFloat(curr.longitude),
|
||
);
|
||
totalDistanceM += distance;
|
||
}
|
||
}
|
||
|
||
const totalDistanceKm = totalDistanceM / 1000;
|
||
|
||
console.log("📊 운행 통계 결과:", {
|
||
tripId,
|
||
totalDistanceKm,
|
||
totalTimeMinutes,
|
||
startTime,
|
||
endTime,
|
||
pointCount: locations.length,
|
||
});
|
||
|
||
return {
|
||
totalDistanceKm,
|
||
totalTimeMinutes,
|
||
startTime,
|
||
endTime,
|
||
};
|
||
} catch (error) {
|
||
console.error("❌ 운행 통계 계산 오류:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 두 좌표 간 거리 계산 (Haversine 공식, 미터 단위)
|
||
*/
|
||
private static calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||
const R = 6371000; // 지구 반경 (미터)
|
||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||
const a =
|
||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||
return R * c;
|
||
}
|
||
|
||
/**
|
||
* 위치 이력 테이블에 저장 (내부 헬퍼)
|
||
* + vehicles 테이블의 latitude/longitude도 함께 업데이트
|
||
*/
|
||
private static async saveLocationToHistory(
|
||
tripId: string | null,
|
||
departure: string | null,
|
||
arrival: string | null,
|
||
departureName: string | null,
|
||
destinationName: string | null,
|
||
vehicleId: number | null,
|
||
tripStatus: string = "active",
|
||
): Promise<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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 즉시 저장 (Quick Insert) 액션 처리
|
||
* 화면에서 선택한 데이터를 특정 테이블에 즉시 저장
|
||
*/
|
||
private static async handleQuickInsert(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||
try {
|
||
console.log("⚡ Quick Insert 액션 실행:", { config, context });
|
||
|
||
const quickInsertConfig = config.quickInsertConfig;
|
||
if (!quickInsertConfig?.targetTable) {
|
||
toast.error("대상 테이블이 설정되지 않았습니다.");
|
||
return false;
|
||
}
|
||
|
||
// ✅ allComponents가 있으면 기존 필수 항목 검증 수행
|
||
if (context.allComponents && context.allComponents.length > 0) {
|
||
console.log("🔍 [handleQuickInsert] 필수 항목 검증 시작:", {
|
||
hasAllComponents: !!context.allComponents,
|
||
allComponentsLength: context.allComponents?.length || 0,
|
||
});
|
||
const requiredValidation = this.validateRequiredFields(context);
|
||
if (!requiredValidation.isValid) {
|
||
console.log("❌ [handleQuickInsert] 필수 항목 누락:", requiredValidation.missingFields);
|
||
toast.error(`필수 항목을 입력해주세요: ${requiredValidation.missingFields.join(", ")}`);
|
||
return false;
|
||
}
|
||
console.log("✅ [handleQuickInsert] 필수 항목 검증 통과");
|
||
}
|
||
|
||
// ✅ quickInsert 전용 검증: component 타입 매핑에서 값이 비어있는지 확인
|
||
const mappingsForValidation = quickInsertConfig.columnMappings || [];
|
||
const missingMappingFields: string[] = [];
|
||
|
||
for (const mapping of mappingsForValidation) {
|
||
// component 타입 매핑은 필수 입력으로 간주
|
||
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
|
||
let value: any = undefined;
|
||
|
||
// 값 가져오기 (formData에서)
|
||
if (mapping.sourceColumnName) {
|
||
value = context.formData?.[mapping.sourceColumnName];
|
||
}
|
||
if (value === undefined || value === null) {
|
||
value = context.formData?.[mapping.sourceComponentId];
|
||
}
|
||
// allComponents에서 컴포넌트 찾아서 columnName으로 시도
|
||
if ((value === undefined || value === null) && context.allComponents) {
|
||
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||
if (comp?.columnName) {
|
||
value = context.formData?.[comp.columnName];
|
||
}
|
||
}
|
||
// targetColumn으로 폴백
|
||
if ((value === undefined || value === null) && mapping.targetColumn) {
|
||
value = context.formData?.[mapping.targetColumn];
|
||
}
|
||
|
||
// 값이 비어있으면 필수 누락으로 처리
|
||
if (value === undefined || value === null || (typeof value === "string" && value.trim() === "")) {
|
||
console.log("❌ [handleQuickInsert] component 매핑 값 누락:", {
|
||
targetColumn: mapping.targetColumn,
|
||
sourceComponentId: mapping.sourceComponentId,
|
||
sourceColumnName: mapping.sourceColumnName,
|
||
value,
|
||
});
|
||
missingMappingFields.push(mapping.targetColumn);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (missingMappingFields.length > 0) {
|
||
console.log("❌ [handleQuickInsert] 필수 입력 항목 누락:", missingMappingFields);
|
||
toast.error(`다음 항목을 입력해주세요: ${missingMappingFields.join(", ")}`);
|
||
return false;
|
||
}
|
||
console.log("✅ [handleQuickInsert] quickInsert 매핑 검증 통과");
|
||
|
||
const { formData, splitPanelContext, userId, userName, companyCode } = context;
|
||
|
||
console.log("⚡ Quick Insert 상세 정보:", {
|
||
targetTable: quickInsertConfig.targetTable,
|
||
columnMappings: quickInsertConfig.columnMappings,
|
||
formData: formData,
|
||
formDataKeys: Object.keys(formData || {}),
|
||
splitPanelContext: splitPanelContext,
|
||
selectedLeftData: splitPanelContext?.selectedLeftData,
|
||
allComponents: context.allComponents,
|
||
userId,
|
||
userName,
|
||
companyCode,
|
||
});
|
||
|
||
// 컬럼 매핑에 따라 저장할 데이터 구성
|
||
const insertData: Record<string, any> = {};
|
||
const columnMappings = quickInsertConfig.columnMappings || [];
|
||
|
||
for (const mapping of columnMappings) {
|
||
console.log("📍 매핑 처리 시작:", mapping);
|
||
|
||
if (!mapping.targetColumn) {
|
||
console.log("📍 targetColumn 없음, 스킵");
|
||
continue;
|
||
}
|
||
|
||
let value: any = undefined;
|
||
|
||
switch (mapping.sourceType) {
|
||
case "component":
|
||
console.log("📍 component 타입 처리:", {
|
||
sourceComponentId: mapping.sourceComponentId,
|
||
sourceColumnName: mapping.sourceColumnName,
|
||
targetColumn: mapping.targetColumn,
|
||
});
|
||
|
||
// 컴포넌트의 현재 값
|
||
if (mapping.sourceComponentId) {
|
||
// 1. sourceColumnName이 있으면 직접 사용 (가장 확실한 방법)
|
||
if (mapping.sourceColumnName) {
|
||
value = formData?.[mapping.sourceColumnName];
|
||
console.log(`📍 방법1 (sourceColumnName): ${mapping.sourceColumnName} = ${value}`);
|
||
}
|
||
|
||
// 2. 없으면 컴포넌트 ID로 직접 찾기
|
||
if (value === undefined) {
|
||
value = formData?.[mapping.sourceComponentId];
|
||
console.log(`📍 방법2 (sourceComponentId): ${mapping.sourceComponentId} = ${value}`);
|
||
}
|
||
|
||
// 3. 없으면 allComponents에서 컴포넌트를 찾아 columnName으로 시도
|
||
if (value === undefined && context.allComponents) {
|
||
const comp = context.allComponents.find((c: any) => c.id === mapping.sourceComponentId);
|
||
console.log("📍 방법3 찾은 컴포넌트:", comp);
|
||
if (comp?.columnName) {
|
||
value = formData?.[comp.columnName];
|
||
console.log(`📍 방법3 (allComponents): ${mapping.sourceComponentId} → ${comp.columnName} = ${value}`);
|
||
}
|
||
}
|
||
|
||
// 4. targetColumn과 같은 이름의 키가 formData에 있으면 사용 (폴백)
|
||
if (value === undefined && mapping.targetColumn && formData?.[mapping.targetColumn] !== undefined) {
|
||
value = formData[mapping.targetColumn];
|
||
console.log(`📍 방법4 (targetColumn 폴백): ${mapping.targetColumn} = ${value}`);
|
||
}
|
||
|
||
// 5. 그래도 없으면 formData의 모든 키를 확인하고 로깅
|
||
if (value === undefined) {
|
||
console.log("📍 방법5: formData에서 값을 찾지 못함. formData 키들:", Object.keys(formData || {}));
|
||
}
|
||
|
||
// sourceColumn이 지정된 경우 해당 속성 추출
|
||
if (mapping.sourceColumn && value && typeof value === "object") {
|
||
value = value[mapping.sourceColumn];
|
||
console.log(`📍 sourceColumn 추출: ${mapping.sourceColumn} = ${value}`);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case "leftPanel":
|
||
console.log("📍 leftPanel 타입 처리:", {
|
||
sourceColumn: mapping.sourceColumn,
|
||
selectedLeftData: splitPanelContext?.selectedLeftData,
|
||
});
|
||
// 좌측 패널 선택 데이터
|
||
if (mapping.sourceColumn && splitPanelContext?.selectedLeftData) {
|
||
value = splitPanelContext.selectedLeftData[mapping.sourceColumn];
|
||
console.log(`📍 leftPanel 값: ${mapping.sourceColumn} = ${value}`);
|
||
}
|
||
break;
|
||
|
||
case "fixed":
|
||
console.log(`📍 fixed 타입 처리: fixedValue = ${mapping.fixedValue}`);
|
||
// 고정값
|
||
value = mapping.fixedValue;
|
||
break;
|
||
|
||
case "currentUser":
|
||
console.log(`📍 currentUser 타입 처리: userField = ${mapping.userField}`);
|
||
// 현재 사용자 정보
|
||
switch (mapping.userField) {
|
||
case "userId":
|
||
value = userId;
|
||
break;
|
||
case "userName":
|
||
value = userName;
|
||
break;
|
||
case "companyCode":
|
||
value = companyCode;
|
||
break;
|
||
}
|
||
console.log(`📍 currentUser 값: ${value}`);
|
||
break;
|
||
|
||
default:
|
||
console.log(`📍 알 수 없는 sourceType: ${mapping.sourceType}`);
|
||
}
|
||
|
||
console.log(`📍 매핑 결과: targetColumn=${mapping.targetColumn}, value=${value}, type=${typeof value}`);
|
||
|
||
if (value !== undefined && value !== null && value !== "") {
|
||
insertData[mapping.targetColumn] = value;
|
||
console.log(`📍 insertData에 추가됨: ${mapping.targetColumn} = ${value}`);
|
||
} else {
|
||
console.log("📍 값이 비어있어서 insertData에 추가 안됨");
|
||
}
|
||
}
|
||
|
||
// 🆕 좌측 패널 선택 데이터에서 자동 매핑 (대상 테이블에 존재하는 컬럼만)
|
||
if (splitPanelContext?.selectedLeftData) {
|
||
const leftData = splitPanelContext.selectedLeftData;
|
||
console.log("📍 좌측 패널 자동 매핑 시작:", leftData);
|
||
|
||
// 대상 테이블의 컬럼 목록 조회
|
||
let targetTableColumns: string[] = [];
|
||
try {
|
||
const columnsResponse = await apiClient.get(
|
||
`/table-management/tables/${quickInsertConfig.targetTable}/columns`,
|
||
);
|
||
if (columnsResponse.data?.success && columnsResponse.data?.data) {
|
||
const columnsData = columnsResponse.data.data.columns || columnsResponse.data.data;
|
||
targetTableColumns = columnsData.map((col: any) => col.columnName || col.column_name || col.name);
|
||
console.log("📍 대상 테이블 컬럼 목록:", targetTableColumns);
|
||
}
|
||
} catch (error) {
|
||
console.error("대상 테이블 컬럼 조회 실패:", error);
|
||
}
|
||
|
||
for (const [key, val] of Object.entries(leftData)) {
|
||
// 이미 매핑된 컬럼은 스킵
|
||
if (insertData[key] !== undefined) {
|
||
console.log(`📍 자동 매핑 스킵 (이미 존재): ${key}`);
|
||
continue;
|
||
}
|
||
|
||
// 대상 테이블에 해당 컬럼이 없으면 스킵
|
||
if (targetTableColumns.length > 0 && !targetTableColumns.includes(key)) {
|
||
console.log(`📍 자동 매핑 스킵 (대상 테이블에 없는 컬럼): ${key}`);
|
||
continue;
|
||
}
|
||
|
||
// 시스템 컬럼 제외 (id, created_date, updated_date, writer 등)
|
||
const systemColumns = ["id", "created_date", "updated_date", "writer", "writer_name"];
|
||
if (systemColumns.includes(key)) {
|
||
console.log(`📍 자동 매핑 스킵 (시스템 컬럼): ${key}`);
|
||
continue;
|
||
}
|
||
|
||
// _label, _name 으로 끝나는 표시용 컬럼 제외
|
||
if (key.endsWith("_label") || key.endsWith("_name")) {
|
||
console.log(`📍 자동 매핑 스킵 (표시용 컬럼): ${key}`);
|
||
continue;
|
||
}
|
||
|
||
// 값이 있으면 자동 추가
|
||
if (val !== undefined && val !== null && val !== "") {
|
||
insertData[key] = val;
|
||
console.log(`📍 자동 매핑 추가: ${key} = ${val}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
console.log("⚡ Quick Insert 최종 데이터:", insertData, "키 개수:", Object.keys(insertData).length);
|
||
|
||
// 필수 데이터 검증
|
||
if (Object.keys(insertData).length === 0) {
|
||
toast.error("저장할 데이터가 없습니다.");
|
||
return false;
|
||
}
|
||
|
||
// 중복 체크
|
||
console.log("📍 중복 체크 설정:", {
|
||
enabled: quickInsertConfig.duplicateCheck?.enabled,
|
||
columns: quickInsertConfig.duplicateCheck?.columns,
|
||
});
|
||
|
||
if (quickInsertConfig.duplicateCheck?.enabled && quickInsertConfig.duplicateCheck?.columns?.length > 0) {
|
||
const duplicateCheckData: Record<string, any> = {};
|
||
for (const col of quickInsertConfig.duplicateCheck.columns) {
|
||
if (insertData[col] !== undefined) {
|
||
// 백엔드가 { value, operator } 형식을 기대하므로 변환
|
||
duplicateCheckData[col] = { value: insertData[col], operator: "equals" };
|
||
}
|
||
}
|
||
|
||
console.log("📍 중복 체크 조건:", duplicateCheckData);
|
||
|
||
if (Object.keys(duplicateCheckData).length > 0) {
|
||
try {
|
||
const checkResponse = await apiClient.post(
|
||
`/table-management/tables/${quickInsertConfig.targetTable}/data`,
|
||
{
|
||
page: 1,
|
||
pageSize: 1,
|
||
search: duplicateCheckData,
|
||
},
|
||
);
|
||
|
||
console.log("📍 중복 체크 응답:", checkResponse.data);
|
||
|
||
// 응답 구조: { success: true, data: { data: [...], total: N } } 또는 { success: true, data: [...] }
|
||
const existingData = checkResponse.data?.data?.data || checkResponse.data?.data || [];
|
||
console.log(
|
||
"📍 기존 데이터:",
|
||
existingData,
|
||
"길이:",
|
||
Array.isArray(existingData) ? existingData.length : 0,
|
||
);
|
||
|
||
if (Array.isArray(existingData) && existingData.length > 0) {
|
||
toast.error(quickInsertConfig.duplicateCheck.errorMessage || "이미 존재하는 데이터입니다.");
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error("중복 체크 오류:", error);
|
||
// 중복 체크 실패해도 저장은 시도
|
||
}
|
||
}
|
||
} else {
|
||
console.log("📍 중복 체크 비활성화 또는 컬럼 미설정");
|
||
}
|
||
|
||
// 데이터 저장
|
||
const response = await apiClient.post(
|
||
`/table-management/tables/${quickInsertConfig.targetTable}/add`,
|
||
insertData,
|
||
);
|
||
|
||
if (response.data?.success) {
|
||
console.log("✅ Quick Insert 저장 성공");
|
||
|
||
// 저장 후 동작 설정 로그
|
||
console.log("📍 afterInsert 설정:", quickInsertConfig.afterInsert);
|
||
|
||
// 🆕 데이터 새로고침 (테이블리스트, 카드 디스플레이 컴포넌트 새로고침)
|
||
// refreshData가 명시적으로 false가 아니면 기본적으로 새로고침 실행
|
||
const shouldRefresh = quickInsertConfig.afterInsert?.refreshData !== false;
|
||
console.log("📍 데이터 새로고침 여부:", shouldRefresh);
|
||
|
||
if (shouldRefresh) {
|
||
console.log("📍 데이터 새로고침 이벤트 발송");
|
||
// 전역 이벤트로 테이블/카드 컴포넌트들에게 새로고침 알림
|
||
if (typeof window !== "undefined") {
|
||
window.dispatchEvent(new CustomEvent("refreshTable"));
|
||
window.dispatchEvent(new CustomEvent("refreshCardDisplay"));
|
||
console.log("✅ refreshTable, refreshCardDisplay 이벤트 발송 완료");
|
||
}
|
||
}
|
||
|
||
// 컴포넌트 값 초기화
|
||
if (quickInsertConfig.afterInsert?.clearComponents && context.onFormDataChange) {
|
||
for (const mapping of columnMappings) {
|
||
if (mapping.sourceType === "component" && mapping.sourceComponentId) {
|
||
// sourceColumnName이 있으면 그것을 사용, 없으면 sourceComponentId 사용
|
||
const fieldName = mapping.sourceColumnName || mapping.sourceComponentId;
|
||
context.onFormDataChange(fieldName, null);
|
||
console.log(`📍 컴포넌트 값 초기화: ${fieldName}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (quickInsertConfig.afterInsert?.showSuccessMessage !== false) {
|
||
toast.success(quickInsertConfig.afterInsert?.successMessage || "저장되었습니다.");
|
||
}
|
||
|
||
return true;
|
||
} else {
|
||
toast.error(response.data?.message || "저장에 실패했습니다.");
|
||
return false;
|
||
}
|
||
} catch (error: any) {
|
||
console.error("❌ Quick Insert 오류:", error);
|
||
toast.error(error.response?.data?.message || "저장 중 오류가 발생했습니다.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 필드 값 변경 액션 처리 (예: status를 active로 변경)
|
||
* 🆕 위치정보 수집 기능 추가
|
||
* 🆕 연속 위치 추적 기능 추가
|
||
*/
|
||
/**
|
||
* 운행알림 및 종료 액션 처리
|
||
* - 위치 수집 + 상태 변경 + 연속 추적 (시작/종료)
|
||
*/
|
||
private static async handleOperationControl(
|
||
config: ButtonActionConfig,
|
||
context: ButtonActionContext,
|
||
): Promise<boolean> {
|
||
try {
|
||
console.log("🔄 운행알림/종료 액션 실행:", { config, context });
|
||
|
||
// 🆕 출발지/도착지 필수 체크 (운행 시작 모드일 때만)
|
||
// updateTrackingMode가 "start"이거나 updateTargetValue가 "active"/"inactive"인 경우
|
||
const isStartMode =
|
||
config.updateTrackingMode === "start" ||
|
||
config.updateTargetValue === "active" ||
|
||
config.updateTargetValue === "inactive";
|
||
|
||
if (isStartMode) {
|
||
// 출발지/도착지 필드명 (기본값: departure, destination)
|
||
const departureField = config.trackingDepartureField || "departure";
|
||
const destinationField = config.trackingArrivalField || "destination";
|
||
|
||
const departure = context.formData?.[departureField];
|
||
const destination = context.formData?.[destinationField];
|
||
|
||
console.log("📍 출발지/도착지 체크:", { departureField, destinationField, departure, destination });
|
||
|
||
if (!departure || departure === "" || !destination || destination === "") {
|
||
toast.error("출발지와 도착지를 먼저 선택해주세요.");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 🆕 공차 추적 중지 (운행 시작 시 공차 추적 종료)
|
||
if (this.emptyVehicleWatchId !== null) {
|
||
this.stopEmptyVehicleTracking();
|
||
console.log("🛑 공차 추적 종료 후 운행 시작");
|
||
}
|
||
|
||
// 🆕 연속 위치 추적 모드 처리
|
||
if (config.updateWithTracking) {
|
||
const trackingConfig: ButtonActionConfig = {
|
||
...config,
|
||
trackingInterval: config.updateTrackingInterval || config.trackingInterval || 10000,
|
||
trackingStatusField: config.updateTargetField,
|
||
trackingStatusTableName: config.updateTableName || context.tableName,
|
||
trackingStatusKeyField: config.updateKeyField,
|
||
trackingStatusKeySourceField: config.updateKeySourceField,
|
||
};
|
||
|
||
if (config.updateTrackingMode === "start") {
|
||
trackingConfig.trackingStatusOnStart = config.updateTargetValue as string;
|
||
return await this.handleTrackingStart(trackingConfig, context);
|
||
} else if (config.updateTrackingMode === "stop") {
|
||
trackingConfig.trackingStatusOnStop = config.updateTargetValue as string;
|
||
return await this.handleTrackingStop(trackingConfig, context);
|
||
}
|
||
}
|
||
|
||
const { formData, tableName, onFormDataChange, onSave } = context;
|
||
|
||
// 변경할 필드 확인
|
||
const targetField = config.updateTargetField;
|
||
const targetValue = config.updateTargetValue;
|
||
const multipleFields = config.updateMultipleFields || [];
|
||
|
||
// 단일 필드 변경이나 다중 필드 변경 중 하나는 있어야 함
|
||
if (!targetField && multipleFields.length === 0 && !config.updateWithGeolocation) {
|
||
toast.error("변경할 필드가 설정되지 않았습니다.");
|
||
return false;
|
||
}
|
||
|
||
// 확인 메시지 표시 (설정된 경우)
|
||
if (config.confirmMessage) {
|
||
const confirmed = window.confirm(config.confirmMessage);
|
||
if (!confirmed) {
|
||
console.log("🔄 필드 값 변경 취소됨 (사용자가 취소)");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 변경할 필드 목록 구성
|
||
const updates: Record<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: "필드 값 교환 중 오류가 발생했습니다.",
|
||
},
|
||
quickInsert: {
|
||
type: "quickInsert",
|
||
successMessage: "저장되었습니다.",
|
||
errorMessage: "저장 중 오류가 발생했습니다.",
|
||
},
|
||
};
|