1064 lines
42 KiB
TypeScript
1064 lines
42 KiB
TypeScript
"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) {
|
||
console.warn("테이블명 또는 그룹핑 컬럼이 없습니다.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log("🔍 그룹 데이터 조회 시작:", {
|
||
tableName: modalState.tableName,
|
||
groupByColumns: modalState.groupByColumns,
|
||
editData: modalState.editData,
|
||
});
|
||
|
||
// 그룹핑 컬럼 값 추출 (예: 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) {
|
||
console.warn("그룹핑 컬럼 값이 없습니다:", modalState.groupByColumns);
|
||
return;
|
||
}
|
||
|
||
console.log("🔍 그룹 조회 요청:", {
|
||
tableName: modalState.tableName,
|
||
groupValues,
|
||
});
|
||
|
||
// 같은 그룹의 모든 레코드 조회 (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,
|
||
});
|
||
|
||
console.log("🔍 그룹 조회 응답:", response);
|
||
|
||
// entityJoinApi는 배열 또는 { data: [] } 형식으로 반환
|
||
const dataArray = Array.isArray(response) ? response : response?.data || [];
|
||
|
||
if (dataArray.length > 0) {
|
||
console.log("✅ 그룹 데이터 조회 성공:", dataArray.length, "건");
|
||
setGroupData(dataArray);
|
||
setOriginalGroupData(JSON.parse(JSON.stringify(dataArray))); // Deep copy
|
||
toast.info(`${dataArray.length}개의 관련 품목을 불러왔습니다.`);
|
||
} else {
|
||
console.warn("그룹 데이터가 없습니다:", response);
|
||
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 {
|
||
const response = await dynamicFormApi.deleteFormDataFromTable(
|
||
deletedItem.id,
|
||
screenData.screenInfo.tableName,
|
||
);
|
||
|
||
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 response = await dynamicFormApi.saveFormData({
|
||
screenId: modalState.screenId!,
|
||
tableName: screenData.screenInfo.tableName,
|
||
data: formData,
|
||
});
|
||
|
||
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이 있는지 확인
|
||
const hasUniversalFormModal = screenData.components.some(
|
||
(c) => {
|
||
// 최상위에 universal-form-modal이 있는 경우
|
||
if (c.componentType === "universal-form-modal") return true;
|
||
// 조건부 컨테이너 내부에 universal-form-modal이 있는 경우
|
||
// (조건부 컨테이너가 있으면 내부 화면에서 universal-form-modal을 사용하는 것으로 가정)
|
||
if (c.componentType === "conditional-container") return true;
|
||
return false;
|
||
}
|
||
);
|
||
|
||
// 🔑 첨부파일 컴포넌트가 행(레코드) 단위로 파일을 저장할 수 있도록 tableName 추가
|
||
const enrichedFormData = {
|
||
...(groupData.length > 0 ? groupData[0] : formData),
|
||
tableName: screenData.screenInfo?.tableName, // 테이블명 추가
|
||
screenId: modalState.screenId, // 화면 ID 추가
|
||
};
|
||
|
||
// 🔍 디버깅: enrichedFormData 확인
|
||
console.log("🔑 [EditModal] enrichedFormData 생성:", {
|
||
"screenData.screenInfo": screenData.screenInfo,
|
||
"screenData.screenInfo?.tableName": screenData.screenInfo?.tableName,
|
||
"enrichedFormData.tableName": enrichedFormData.tableName,
|
||
"enrichedFormData.id": enrichedFormData.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이 있으면 onSave 전달 안 함 (자체 저장 로직 사용)
|
||
// ModalRepeaterTable만 있으면 기존대로 onSave 전달 (호환성 유지)
|
||
onSave={hasUniversalFormModal ? undefined : handleSave}
|
||
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;
|