ERP-node/frontend/components/screen/EditModal.tsx

1106 lines
44 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

"use client";
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
import { useAuth } from "@/hooks/useAuth";
interface EditModalState {
isOpen: boolean;
screenId: number | null;
title: string;
description?: string;
modalSize: "sm" | "md" | "lg" | "xl";
editData: Record<string, any>;
onSave?: () => void;
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
tableName?: string; // 🆕 테이블명 (그룹 조회용)
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
saveButtonConfig?: {
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
}
interface EditModalProps {
className?: string;
}
/**
* 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색)
* action.type이 "save"인 button-primary 컴포넌트를 찾음
*/
const findSaveButtonInComponents = (components: any[]): any | null => {
if (!components || !Array.isArray(components)) return null;
for (const comp of components) {
// button-primary이고 action.type이 save인 경우
if (
comp.componentType === "button-primary" &&
comp.componentConfig?.action?.type === "save"
) {
return comp;
}
// conditional-container의 sections 내부 탐색
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
for (const section of comp.componentConfig.sections) {
if (section.screenId) {
// 조건부 컨테이너의 내부 화면은 별도로 로드해야 함
// 여기서는 null 반환하고, loadSaveButtonConfig에서 처리
continue;
}
}
}
// 자식 컴포넌트가 있으면 재귀 탐색
if (comp.children && Array.isArray(comp.children)) {
const found = findSaveButtonInComponents(comp.children);
if (found) return found;
}
}
return null;
};
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
const { user } = useAuth();
const [modalState, setModalState] = useState<EditModalState>({
isOpen: false,
screenId: null,
title: "",
description: "",
modalSize: "md",
editData: {},
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
buttonConfig: undefined,
buttonContext: undefined,
saveButtonConfig: undefined,
});
const [screenData, setScreenData] = useState<{
components: ComponentData[];
screenInfo: any;
} | null>(null);
const [loading, setLoading] = useState(false);
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
offsetX?: number;
offsetY?: number;
} | null>(null);
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({});
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
if (components.length === 0) {
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
}
// 모든 컴포넌트의 경계 찾기
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0");
const y = parseFloat(component.position?.y?.toString() || "0");
const width = parseFloat(component.size?.width?.toString() || "100");
const height = parseFloat(component.size?.height?.toString() || "40");
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x + width);
maxY = Math.max(maxY, y + height);
});
// 실제 컨텐츠 크기 계산
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
// 적절한 여백 추가 (주석처리 - 사용자 설정 크기 그대로 사용)
// const paddingX = 40;
// const paddingY = 40;
const finalWidth = Math.max(contentWidth, 400); // padding 제거
const finalHeight = Math.max(contentHeight, 300); // padding 제거
return {
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX), // paddingX 제거
offsetY: Math.max(0, minY), // paddingY 제거
};
};
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
const loadSaveButtonConfig = async (targetScreenId: number): Promise<{
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
} | null> => {
try {
// 1. 대상 화면의 레이아웃 조회
const layoutData = await screenApi.getLayout(targetScreenId);
if (!layoutData?.components) {
console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId);
return null;
}
// 2. 저장 버튼 찾기
let saveButton = findSaveButtonInComponents(layoutData.components);
// 3. conditional-container가 있는 경우 내부 화면도 탐색
if (!saveButton) {
for (const comp of layoutData.components) {
if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) {
for (const section of comp.componentConfig.sections) {
if (section.screenId) {
try {
const innerLayoutData = await screenApi.getLayout(section.screenId);
saveButton = findSaveButtonInComponents(innerLayoutData?.components || []);
if (saveButton) {
// console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
// sectionScreenId: section.screenId,
// sectionLabel: section.label,
// });
break;
}
} catch (innerError) {
// console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
}
}
}
if (saveButton) break;
}
}
}
if (!saveButton) {
// console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId);
return null;
}
// 4. webTypeConfig에서 제어로직 설정 추출
const webTypeConfig = saveButton.webTypeConfig;
if (webTypeConfig?.enableDataflowControl) {
const config = {
enableDataflowControl: webTypeConfig.enableDataflowControl,
dataflowConfig: webTypeConfig.dataflowConfig,
dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after",
};
// console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
return config;
}
// console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
return null;
} catch (error) {
// console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
return null;
}
};
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
if (screenId) {
const config = await loadSaveButtonConfig(screenId);
if (config) {
saveButtonConfig = config;
}
}
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
modalSize: modalSize || "lg",
editData: editData || {},
onSave,
groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명
buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드)
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨
setOriginalData(isCreateMode ? {} : editData || {});
if (isCreateMode) {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData);
}
};
const handleCloseEditModal = () => {
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("⚠️ onSave 콜백 에러:", callbackError);
}
}
// 모달 닫기
handleClose();
};
window.addEventListener("openEditModal", handleOpenEditModal as EventListener);
window.addEventListener("closeEditModal", handleCloseEditModal);
return () => {
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
// 화면 데이터 로딩
useEffect(() => {
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
loadGroupData();
}
}
}, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
// 🆕 그룹 데이터 조회 함수
const loadGroupData = async () => {
if (!modalState.tableName || !modalState.groupByColumns || modalState.groupByColumns.length === 0) {
return;
}
try {
// 그룹핑 컬럼 값 추출 (예: order_no = "ORD-20251124-001")
const groupValues: Record<string, any> = {};
modalState.groupByColumns.forEach((column) => {
if (modalState.editData[column]) {
groupValues[column] = modalState.editData[column];
}
});
if (Object.keys(groupValues).length === 0) {
return;
}
// 같은 그룹의 모든 레코드 조회 (entityJoinApi 사용)
const { entityJoinApi } = await import("@/lib/api/entityJoin");
const response = await entityJoinApi.getTableDataWithJoins(modalState.tableName, {
page: 1,
size: 1000,
search: groupValues, // search 파라미터로 전달 (백엔드에서 WHERE 조건으로 처리)
enableEntityJoin: true,
});
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
const dataArray = Array.isArray(response) ? response : response?.data || [];
if (dataArray.length > 0) {
setGroupData(dataArray);
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
} else {
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
}
} catch (error: any) {
console.error("그룹 데이터 조회 오류:", error);
toast.error("관련 데이터를 불러오는 중 오류가 발생했습니다.");
setGroupData([modalState.editData]); // 기본값: 선택된 행만
setOriginalGroupData([JSON.parse(JSON.stringify(modalState.editData))]);
}
};
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
// console.log("화면 데이터 로딩 시작:", screenId);
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
// console.log("API 응답:", { screenInfo, layoutData });
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면의 실제 크기 계산
const dimensions = calculateScreenDimensions(components);
setScreenDimensions(dimensions);
setScreenData({
components,
screenInfo: screenInfo,
});
// console.log("화면 데이터 설정 완료:", {
// componentsCount: components.length,
// dimensions,
// screenInfo,
// });
} else {
throw new Error("화면 데이터가 없습니다");
}
} catch (error) {
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
} finally {
setLoading(false);
}
};
const handleClose = () => {
setModalState({
isOpen: false,
screenId: null,
title: "",
description: "",
modalSize: "md",
editData: {},
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
});
setScreenData(null);
setFormData({});
setOriginalData({});
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
};
// 저장 버튼 클릭 시 - UPDATE 액션 실행
const handleSave = async (saveData?: any) => {
// universal-form-modal 등에서 자체 저장 완료 후 호출된 경우 스킵
if (saveData?._saveCompleted) {
console.log("[EditModal] 자체 저장 완료된 컴포넌트에서 호출됨 - 저장 스킵");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
handleClose();
return;
}
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;
}
try {
// 🆕 그룹 데이터가 있는 경우: 모든 품목 일괄 처리 (추가/수정/삭제)
if (groupData.length > 0 || originalGroupData.length > 0) {
console.log("🔄 그룹 데이터 일괄 처리 시작:", {
groupDataLength: groupData.length,
originalGroupDataLength: originalGroupData.length,
groupData,
originalGroupData,
tableName: screenData.screenInfo.tableName,
screenId: modalState.screenId,
});
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
const normalizeDateField = (value: any): string | null => {
if (!value) return null;
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
if (value instanceof Date || typeof value === "string") {
try {
const date = new Date(value);
if (isNaN(date.getTime())) return null;
// YYYY-MM-DD 형식으로 변환
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
} catch (error) {
console.warn("날짜 변환 실패:", value, error);
return null;
}
}
return null;
};
// 날짜 필드 목록
const dateFields = ["item_due_date", "delivery_date", "due_date", "order_date"];
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
// 1⃣ 신규 품목 추가 (id가 없는 항목)
for (const currentData of groupData) {
if (!currentData.id) {
console.log(" 신규 품목 추가:", currentData);
console.log("📋 [신규 품목] currentData 키 목록:", Object.keys(currentData));
// 🆕 모든 데이터를 포함 (id 제외)
const insertData: Record<string, any> = { ...currentData };
console.log("📦 [신규 품목] 복사 직후 insertData:", insertData);
console.log("📋 [신규 품목] insertData 키 목록:", Object.keys(insertData));
delete insertData.id; // id는 자동 생성되므로 제거
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
dateFields.forEach((fieldName) => {
if (insertData[fieldName]) {
const normalizedDate = normalizeDateField(insertData[fieldName]);
if (normalizedDate) {
insertData[fieldName] = normalizedDate;
console.log(`📅 [날짜 정규화] ${fieldName}: ${currentData[fieldName]}${normalizedDate}`);
}
}
});
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
modalState.groupByColumns.forEach((colName) => {
// 기존 품목(originalGroupData[0])에서 groupByColumns 값 가져오기
const referenceData = originalGroupData[0] || groupData.find((item) => item.id);
if (referenceData && referenceData[colName]) {
insertData[colName] = referenceData[colName];
console.log(`🔑 [신규 품목] ${colName} 값 추가:`, referenceData[colName]);
}
});
}
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
// formData에서 품목별 필드가 아닌 공통 필드를 복사
const commonFields = [
"partner_id", // 거래처
"manager_id", // 담당자
"delivery_partner_id", // 납품처
"delivery_address", // 납품장소
"memo", // 메모
"order_date", // 주문일
"due_date", // 납기일
"shipping_method", // 배송방법
"status", // 상태
"sales_type", // 영업유형
];
commonFields.forEach((fieldName) => {
// formData에 값이 있으면 추가
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
// 날짜 필드인 경우 정규화
if (dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(formData[fieldName]);
if (normalizedDate) {
insertData[fieldName] = normalizedDate;
console.log(`🔗 [공통 필드 - 날짜] ${fieldName} 값 추가:`, normalizedDate);
}
} else {
insertData[fieldName] = formData[fieldName];
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
}
}
});
console.log("📦 [신규 품목] 최종 insertData:", insertData);
console.log("📋 [신규 품목] 최종 insertData 키 목록:", Object.keys(insertData));
try {
const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId || 0,
tableName: screenData.screenInfo.tableName,
data: insertData,
});
if (response.success) {
insertedCount++;
console.log("✅ 신규 품목 추가 성공:", response.data);
} else {
console.error("❌ 신규 품목 추가 실패:", response.message);
}
} catch (error: any) {
console.error("❌ 신규 품목 추가 오류:", error);
}
}
}
// 2⃣ 기존 품목 수정 (id가 있는 항목)
for (const currentData of groupData) {
if (currentData.id) {
// id 기반 매칭 (인덱스 기반 X)
const originalItemData = originalGroupData.find((orig) => orig.id === currentData.id);
if (!originalItemData) {
console.warn(`원본 데이터를 찾을 수 없습니다 (id: ${currentData.id})`);
continue;
}
// 🆕 값 정규화 함수 (타입 통일)
const normalizeValue = (val: any, fieldName?: string): any => {
if (val === null || val === undefined || val === "") return null;
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
if (fieldName && dateFields.includes(fieldName)) {
const normalizedDate = normalizeDateField(val);
return normalizedDate;
}
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
}
return val;
};
// 변경된 필드만 추출 (id 제외)
const changedData: Record<string, any> = {};
Object.keys(currentData).forEach((key) => {
// id는 변경 불가
if (key === "id") {
return;
}
// 🆕 타입 정규화 후 비교
const currentValue = normalizeValue(currentData[key], key);
const originalValue = normalizeValue(originalItemData[key], key);
// 값이 변경된 경우만 포함
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
}
});
// 변경사항이 없으면 스킵
if (Object.keys(changedData).length === 0) {
console.log(`변경사항 없음 (id: ${currentData.id})`);
continue;
}
// UPDATE 실행
try {
const response = await dynamicFormApi.updateFormDataPartial(
currentData.id,
originalItemData,
changedData,
screenData.screenInfo.tableName,
);
if (response.success) {
updatedCount++;
console.log(`✅ 품목 수정 성공 (id: ${currentData.id})`);
} else {
console.error(`❌ 품목 수정 실패 (id: ${currentData.id}):`, response.message);
}
} catch (error: any) {
console.error(`❌ 품목 수정 오류 (id: ${currentData.id}):`, error);
}
}
}
// 3⃣ 삭제된 품목 제거 (원본에는 있지만 현재 데이터에는 없는 항목)
const currentIds = new Set(groupData.map((item) => item.id).filter(Boolean));
const deletedItems = originalGroupData.filter((orig) => orig.id && !currentIds.has(orig.id));
for (const deletedItem of deletedItems) {
console.log("🗑️ 품목 삭제:", deletedItem);
try {
// screenId 전달하여 제어관리 실행 가능하도록 함
const response = await dynamicFormApi.deleteFormDataFromTable(
deletedItem.id,
screenData.screenInfo.tableName,
modalState.screenId || screenData.screenInfo?.id,
);
if (response.success) {
deletedCount++;
console.log(`✅ 품목 삭제 성공 (id: ${deletedItem.id})`);
} else {
console.error(`❌ 품목 삭제 실패 (id: ${deletedItem.id}):`, response.message);
}
} catch (error: any) {
console.error(`❌ 품목 삭제 오류 (id: ${deletedItem.id}):`, error);
}
}
// 결과 메시지
const messages: string[] = [];
if (insertedCount > 0) messages.push(`${insertedCount}개 추가`);
if (updatedCount > 0) messages.push(`${updatedCount}개 수정`);
if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`);
if (messages.length > 0) {
toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`);
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("⚠️ onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", {
hasSaveButtonConfig: !!modalState.saveButtonConfig,
hasButtonConfig: !!modalState.buttonConfig,
controlConfig,
});
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
// buttonActions의 executeAfterSaveControl 동적 import
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
// 제어로직 실행
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData: modalState.editData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
} else {
console.log(" [EditModal] 저장 후 실행할 제어로직 없음");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
// 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시)
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
toast.info("변경된 내용이 없습니다.");
handleClose();
}
return;
}
// originalData가 비어있으면 INSERT, 있으면 UPDATE
const isCreateMode = Object.keys(originalData).length === 0;
if (isCreateMode) {
// INSERT 모드
console.log("[EditModal] INSERT 모드 - 새 데이터 생성:", formData);
// 🆕 채번 규칙 할당 처리 (저장 시점에 실제 순번 증가)
const dataToSave = { ...formData };
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(`🎯 [EditModal] 채번 규칙 발견: ${fieldName} → 규칙 ${value}`);
}
}
// 채번 규칙이 있는 필드에 대해 allocateCode 호출
if (Object.keys(fieldsWithNumbering).length > 0) {
console.log("🎯 [EditModal] 채번 규칙 할당 시작");
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
let hasAllocationFailure = false;
const failedFields: string[] = [];
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
try {
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
const allocateResult = await allocateNumberingCode(ruleId);
if (allocateResult.success && allocateResult.data?.generatedCode) {
const newCode = allocateResult.data.generatedCode;
console.log(`✅ [EditModal] ${fieldName} 새 코드 할당: ${dataToSave[fieldName]}${newCode}`);
dataToSave[fieldName] = newCode;
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
}
} catch (allocateError) {
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
if (!dataToSave[fieldName] || dataToSave[fieldName] === "") {
hasAllocationFailure = true;
failedFields.push(fieldName);
}
}
}
// 채번 규칙 할당 실패 시 저장 중단
if (hasAllocationFailure) {
const fieldNames = failedFields.join(", ");
toast.error(`채번 규칙 할당에 실패했습니다 (${fieldNames}). 화면 설정에서 채번 규칙을 확인해주세요.`);
console.error(`❌ [EditModal] 채번 규칙 할당 실패로 저장 중단. 실패 필드: ${fieldNames}`);
return;
}
// _numberingRuleId 필드 제거 (실제 저장하지 않음)
for (const key of Object.keys(dataToSave)) {
if (key.endsWith("_numberingRuleId")) {
delete dataToSave[key];
}
}
}
console.log("[EditModal] 최종 저장 데이터:", dataToSave);
const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId!,
tableName: screenData.screenInfo.tableName,
data: dataToSave,
});
if (response.success) {
toast.success("데이터가 생성되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
throw new Error(response.message || "생성에 실패했습니다.");
}
} else {
// UPDATE 모드 - 기존 로직
const changedData: Record<string, any> = {};
Object.keys(formData).forEach((key) => {
if (formData[key] !== originalData[key]) {
changedData[key] = formData[key];
}
});
if (Object.keys(changedData).length === 0) {
toast.info("변경된 내용이 없습니다.");
handleClose();
return;
}
// 기본키 확인 (id 또는 첫 번째 키)
const recordId = originalData.id || Object.values(originalData)[0];
// UPDATE 액션 실행
const response = await dynamicFormApi.updateFormDataPartial(
recordId,
originalData,
changedData,
screenData.screenInfo.tableName,
);
if (response.success) {
toast.success("데이터가 수정되었습니다.");
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("onSave 콜백 에러:", callbackError);
}
}
// 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어)
// 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig)
try {
const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig;
console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig });
if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") {
console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig);
const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions");
await ButtonActionExecutor.executeAfterSaveControl(
controlConfig,
{
formData,
screenId: modalState.buttonContext?.screenId || modalState.screenId,
tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName,
userId: user?.userId,
companyCode: user?.companyCode,
onRefresh: modalState.onSave,
}
);
console.log("✅ [EditModal] 제어로직 실행 완료");
}
} catch (controlError) {
console.error("❌ [EditModal] 제어로직 실행 오류:", controlError);
toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
}
}
} catch (error: any) {
console.error("❌ 수정 실패:", error);
toast.error(error.message || "데이터 수정 중 오류가 발생했습니다.");
}
};
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: undefined, // undefined로 변경 - defaultWidth/defaultHeight 사용
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4
const extraPadding = 24; // 추가 여백 (안전 마진)
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw",
maxHeight: "95vh",
},
};
};
const modalStyle = getModalStyle();
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
{modalState.description && !loading && (
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</div>
</DialogHeader>
<div className="flex flex-1 items-center justify-center overflow-y-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-track]:bg-transparent">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2 border-blue-600"></div>
<p className="text-muted-foreground"> ...</p>
</div>
</div>
) : screenData ? (
<div
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: (screenDimensions?.height || 600) + 30, // 라벨 공간 추가
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
},
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
// 🆕 UniversalFormModal이 있는지 확인 (자체 저장 로직 사용)
// 최상위 컴포넌트에 universal-form-modal이 있는지 확인
// ⚠️ 수정: conditional-container는 제외 (groupData가 있으면 EditModal.handleSave 사용)
const hasUniversalFormModal = screenData.components.some(
(c) => {
// 최상위에 universal-form-modal이 있는 경우만 자체 저장 로직 사용
if (c.componentType === "universal-form-modal") return true;
return false;
}
);
// 🆕 그룹 데이터가 있으면 EditModal.handleSave 사용 (일괄 저장)
const shouldUseEditModalSave = groupData.length > 0 || !hasUniversalFormModal;
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
screenId: modalState.screenId, // 화면 ID 추가
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={enrichedFormData}
originalData={originalData} // 🆕 원본 데이터 전달 (수정 모드에서 UniversalFormModal 초기화용)
onFormDataChange={(fieldName, value) => {
// 🆕 그룹 데이터가 있으면 처리
if (groupData.length > 0) {
// ModalRepeaterTable의 경우 배열 전체를 받음
if (Array.isArray(value)) {
setGroupData(value);
} else {
// 일반 필드는 모든 항목에 동일하게 적용
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
// 🆕 그룹 데이터가 있거나 UniversalFormModal이 없으면 EditModal.handleSave 사용
// groupData가 있으면 일괄 저장을 위해 반드시 EditModal.handleSave 사용
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupedDataProp}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default EditModal;