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

1509 lines
62 KiB
TypeScript
Raw Normal View History

2025-09-18 18:49:30 +09:00
"use client";
import React, { useState, useEffect, useMemo } from "react";
2025-11-05 16:36:32 +09:00
import {
2025-12-05 10:46:10 +09:00
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
2025-10-30 12:08:58 +09:00
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
2025-09-18 18:49:30 +09:00
import { screenApi } from "@/lib/api/screen";
2025-10-30 12:08:58 +09:00
import { ComponentData } from "@/types/screen";
import { toast } from "sonner";
import { dynamicFormApi } from "@/lib/api/dynamicForm";
2025-11-05 16:36:32 +09:00
import { useAuth } from "@/hooks/useAuth";
import { ConditionalZone, LayerDefinition } from "@/types/screen-management";
import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter";
2025-09-18 18:49:30 +09:00
2025-10-30 12:08:58 +09:00
interface EditModalState {
2025-09-18 18:49:30 +09:00
isOpen: boolean;
2025-10-30 12:08:58 +09:00
screenId: number | null;
title: string;
description?: string;
modalSize: "sm" | "md" | "lg" | "xl";
editData: Record<string, any>;
2025-09-18 18:49:30 +09:00
onSave?: () => void;
groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"])
tableName?: string; // 🆕 테이블명 (그룹 조회용)
buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용)
buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등)
saveButtonConfig?: {
enableDataflowControl?: boolean;
dataflowConfig?: any;
dataflowTiming?: string;
}; // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
2025-09-18 18:49:30 +09:00
}
2025-10-30 12:08:58 +09:00
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;
};
2025-10-30 12:08:58 +09:00
export const EditModal: React.FC<EditModalProps> = ({ className }) => {
2025-11-05 16:36:32 +09:00
const { user } = useAuth();
2025-10-30 12:08:58 +09:00
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,
menuObjid: undefined,
2025-10-30 12:08:58 +09:00
});
const [screenData, setScreenData] = useState<{
components: ComponentData[];
screenInfo: any;
} | null>(null);
2025-09-18 18:49:30 +09:00
2025-10-30 12:08:58 +09:00
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>>({});
// INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음)
// true = INSERT (등록/복사), false = UPDATE (수정)
// originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용
const [isCreateModeFlag, setIsCreateModeFlag] = useState<boolean>(true);
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
const [originalGroupData, setOriginalGroupData] = useState<Record<string, any>[]>([]);
2025-10-30 12:08:58 +09:00
// 🆕 조건부 레이어 상태 (Zone 기반)
const [zones, setZones] = useState<ConditionalZone[]>([]);
const [conditionalLayers, setConditionalLayers] = useState<LayerDefinition[]>([]);
2025-10-30 12:08:58 +09:00
// 화면의 실제 크기 계산 함수 (ScreenModal과 동일)
const calculateScreenDimensions = (components: ComponentData[]) => {
2025-09-18 18:49:30 +09:00
if (components.length === 0) {
2025-10-30 12:08:58 +09:00
return {
width: 400,
height: 300,
offsetX: 0,
offsetY: 0,
};
2025-09-18 18:49:30 +09:00
}
2025-10-30 12:08:58 +09:00
// 모든 컴포넌트의 경계 찾기
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;
2025-10-30 12:08:58 +09:00
const finalWidth = Math.max(contentWidth, 400); // padding 제거
const finalHeight = Math.max(contentHeight, 300); // padding 제거
2025-10-30 12:08:58 +09:00
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 제거
2025-10-30 12:08:58 +09:00
};
2025-09-18 18:49:30 +09:00
};
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
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) {
2026-01-06 13:08:33 +09:00
// console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", {
// sectionScreenId: section.screenId,
// sectionLabel: section.label,
// });
break;
}
} catch (innerError) {
2026-01-06 13:08:33 +09:00
// console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId);
}
}
}
if (saveButton) break;
}
}
}
if (!saveButton) {
2026-01-06 13:08:33 +09:00
// 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",
};
2026-01-06 13:08:33 +09:00
// console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config);
return config;
}
2026-01-06 13:08:33 +09:00
// console.log("[EditModal] 저장 버튼에 제어로직 설정 없음");
return null;
} catch (error) {
2026-01-06 13:08:33 +09:00
// console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error);
return null;
}
};
2025-10-30 12:08:58 +09:00
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = async (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext, menuObjid } = event.detail;
// 🆕 모달 내부 저장 버튼의 제어로직 설정 조회
let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined;
if (screenId) {
const config = await loadSaveButtonConfig(screenId);
if (config) {
saveButtonConfig = config;
}
}
2025-10-30 12:08:58 +09:00
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
modalSize: modalSize || "lg",
editData: editData || {},
onSave,
groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명
buttonConfig, // 🆕 버튼 설정
buttonContext, // 🆕 버튼 컨텍스트
saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정
menuObjid, // 🆕 메뉴 OBJID (카테고리 스코프용)
2025-10-30 12:08:58 +09:00
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
// originalData: changedData 계산(PATCH)에만 사용
// INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {});
// INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
setIsCreateModeFlag(!!isCreateMode);
console.log("[EditModal] 모달 열림:", {
mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
hasEditData: !!editData,
editDataId: editData?.id,
isCreateMode,
});
2025-10-30 12:08:58 +09:00
};
const handleCloseEditModal = () => {
// 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침)
if (modalState.onSave) {
try {
modalState.onSave();
} catch (callbackError) {
console.error("⚠️ onSave 콜백 에러:", callbackError);
}
}
// 모달 닫기
2025-10-30 12:08:58 +09:00
handleClose();
};
2025-10-30 12:08:58 +09:00
window.addEventListener("openEditModal", handleOpenEditModal as EventListener);
window.addEventListener("closeEditModal", handleCloseEditModal);
2025-10-30 12:03:50 +09:00
return () => {
2025-10-30 12:08:58 +09:00
window.removeEventListener("openEditModal", handleOpenEditModal as EventListener);
window.removeEventListener("closeEditModal", handleCloseEditModal);
};
}, [modalState.onSave]); // modalState.onSave를 의존성에 추가하여 최신 콜백 참조
2025-09-18 18:49:30 +09:00
2025-10-30 12:08:58 +09:00
// 화면 데이터 로딩
2025-09-18 18:49:30 +09:00
useEffect(() => {
2025-10-30 12:08:58 +09:00
if (modalState.isOpen && modalState.screenId) {
loadScreenData(modalState.screenId);
// 🆕 그룹 데이터 조회 (groupByColumns가 있는 경우)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0 && modalState.tableName) {
loadGroupData();
}
2025-09-18 18:49:30 +09:00
}
2025-12-11 13:25:13 +09:00
}, [modalState.isOpen, modalState.screenId, modalState.groupByColumns, modalState.tableName]);
2025-09-18 18:49:30 +09:00
// 🆕 그룹 데이터 조회 함수
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))]);
}
};
2025-10-30 12:08:58 +09:00
const loadScreenData = async (screenId: number) => {
try {
setLoading(true);
2025-09-18 18:49:30 +09:00
2025-10-30 12:08:58 +09:00
// 화면 정보와 레이아웃 데이터 로딩
const [screenInfo, layoutData] = await Promise.all([
screenApi.getScreen(screenId),
screenApi.getLayout(screenId),
]);
if (screenInfo && layoutData) {
const components = layoutData.components || [];
// 화면의 실제 크기 계산
const dimensions = calculateScreenDimensions(components);
setScreenDimensions(dimensions);
setScreenData({
components,
screenInfo: screenInfo,
});
// 🆕 조건부 레이어/존 로드 (await으로 에러 포착)
console.log("[EditModal] 화면 데이터 로드 완료, 조건부 레이어 로드 시작:", screenId);
try {
await loadConditionalLayersAndZones(screenId, components);
} catch (layerErr) {
console.error("[EditModal] 조건부 레이어 로드 에러:", layerErr);
}
2025-10-30 12:08:58 +09:00
} else {
throw new Error("화면 데이터가 없습니다");
2025-09-18 18:49:30 +09:00
}
2025-10-30 12:08:58 +09:00
} catch (error) {
console.error("화면 데이터 로딩 오류:", error);
toast.error("화면을 불러오는 중 오류가 발생했습니다.");
handleClose();
} finally {
setLoading(false);
}
};
2025-09-18 18:49:30 +09:00
// 🆕 조건부 레이어 & 존 로드 함수
const loadConditionalLayersAndZones = async (screenId: number, baseComponents: ComponentData[]) => {
console.log("[EditModal] loadConditionalLayersAndZones 호출됨:", screenId);
try {
// 레이어 목록 & 존 목록 병렬 로드
console.log("[EditModal] API 호출 시작: getScreenLayers, getScreenZones");
const [layersRes, zonesRes] = await Promise.all([
screenApi.getScreenLayers(screenId),
screenApi.getScreenZones(screenId),
]);
console.log("[EditModal] API 응답:", { layers: layersRes?.length, zones: zonesRes?.length });
const loadedLayers = layersRes || [];
const loadedZones: ConditionalZone[] = zonesRes || [];
setZones(loadedZones);
// 기본 레이어(layer_id=1) 제외한 조건부 레이어 처리
const nonBaseLayers = loadedLayers.filter((l: any) => l.layer_id !== 1);
if (nonBaseLayers.length === 0) {
setConditionalLayers([]);
return;
}
// 각 조건부 레이어의 컴포넌트 로드
const layerDefinitions: LayerDefinition[] = [];
for (const layer of nonBaseLayers) {
try {
const layerLayout = await screenApi.getLayerLayout(screenId, layer.layer_id);
let layerComponents: ComponentData[] = [];
if (layerLayout && isValidV2Layout(layerLayout)) {
const legacyLayout = convertV2ToLegacy(layerLayout);
layerComponents = (legacyLayout?.components || []) as unknown as ComponentData[];
} else if (layerLayout?.components) {
layerComponents = layerLayout.components;
}
// condition_config에서 zone_id, condition_value 추출
const conditionConfig = layer.condition_config || {};
const layerZoneId = conditionConfig.zone_id;
const layerConditionValue = conditionConfig.condition_value;
// 이 레이어가 속한 Zone 찾기
const associatedZone = loadedZones.find(
(z) => z.zone_id === layerZoneId
);
layerDefinitions.push({
id: `layer-${layer.layer_id}`,
name: layer.layer_name || `레이어 ${layer.layer_id}`,
type: "conditional",
zIndex: layer.layer_id,
isVisible: false,
isLocked: false,
zoneId: layerZoneId,
conditionValue: layerConditionValue,
condition: associatedZone
? {
targetComponentId: associatedZone.trigger_component_id || "",
operator: (associatedZone.trigger_operator || "eq") as any,
value: layerConditionValue || "",
}
: undefined,
components: layerComponents,
} as LayerDefinition & { components: ComponentData[] });
} catch (layerError) {
console.warn(`[EditModal] 레이어 ${layer.layer_id} 로드 실패:`, layerError);
}
}
console.log("[EditModal] 조건부 레이어 로드 완료:", layerDefinitions.length, "개",
layerDefinitions.map((l) => ({
id: l.id,
name: l.name,
conditionValue: l.conditionValue,
condition: l.condition,
}))
);
setConditionalLayers(layerDefinitions);
} catch (error) {
console.warn("[EditModal] 조건부 레이어 로드 실패:", error);
}
};
// 🆕 조건부 레이어 활성화 평가 (formData 변경 시)
const activeConditionalLayerIds = useMemo(() => {
if (conditionalLayers.length === 0) return [];
const newActiveIds: string[] = [];
const allComponents = screenData?.components || [];
conditionalLayers.forEach((layer) => {
const layerWithComponents = layer as LayerDefinition & { components: ComponentData[] };
if (layerWithComponents.condition) {
const { targetComponentId, operator, value } = layerWithComponents.condition;
if (!targetComponentId) return;
// 트리거 컴포넌트의 columnName 찾기
// V2 레이아웃: overrides.columnName, 레거시: componentConfig.columnName
const targetComponent = allComponents.find((c) => c.id === targetComponentId);
const fieldKey =
(targetComponent as any)?.overrides?.columnName ||
(targetComponent as any)?.columnName ||
(targetComponent as any)?.componentConfig?.columnName ||
targetComponentId;
const currentFormData = groupData.length > 0 ? groupData[0] : formData;
const targetValue = currentFormData[fieldKey];
let isMatch = false;
switch (operator) {
case "eq":
isMatch = String(targetValue ?? "") === String(value ?? "");
break;
case "neq":
isMatch = String(targetValue ?? "") !== String(value ?? "");
break;
case "in":
if (Array.isArray(value)) {
isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) {
isMatch = value.split(",").map((v) => v.trim()).includes(String(targetValue ?? ""));
}
break;
}
// 디버그 로깅
console.log("[EditModal] 레이어 조건 평가:", {
layerId: layer.id,
layerName: layer.name,
targetComponentId,
fieldKey,
targetValue: targetValue !== undefined ? String(targetValue) : "(없음)",
conditionValue: String(value),
operator,
isMatch,
componentFound: !!targetComponent,
});
if (isMatch) {
newActiveIds.push(layer.id);
}
}
});
return newActiveIds;
}, [formData, groupData, conditionalLayers, screenData?.components]);
// 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기
const activeConditionalComponents = useMemo(() => {
return conditionalLayers
.filter((layer) => activeConditionalLayerIds.includes(layer.id))
.flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []);
}, [conditionalLayers, activeConditionalLayerIds]);
2025-10-30 12:08:58 +09:00
const handleClose = () => {
setModalState({
isOpen: false,
screenId: null,
title: "",
description: "",
modalSize: "md",
editData: {},
onSave: undefined,
groupByColumns: undefined,
tableName: undefined,
2025-10-30 12:08:58 +09:00
});
setScreenData(null);
setFormData({});
setZones([]);
setConditionalLayers([]);
2025-10-30 12:08:58 +09:00
setOriginalData({});
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
2025-10-30 12:08:58 +09:00
};
2025-09-18 18:49:30 +09:00
2025-10-30 12:08:58 +09:00
// 저장 버튼 클릭 시 - 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;
}
2025-10-30 12:08:58 +09:00
if (!screenData?.screenInfo?.tableName) {
toast.error("테이블 정보가 없습니다.");
return;
}
2025-09-18 18:49:30 +09:00
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}`);
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
let finalValue = dateFields.includes(key) ? currentValue : currentData[key];
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (Array.isArray(finalValue)) {
const isRepeaterData = finalValue.length > 0 &&
typeof finalValue[0] === "object" &&
finalValue[0] !== null &&
("_targetTable" in finalValue[0] || "_isNewItem" in finalValue[0] || "_existingRecord" in finalValue[0]);
if (!isRepeaterData) {
// 🔧 손상된 값 필터링 헬퍼
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
const validValues = finalValue
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal 그룹UPDATE] 배열→문자열 변환: ${key}`, { original: finalValue.length, valid: validValues.length, converted: stringValue });
finalValue = stringValue;
}
}
changedData[key] = finalValue;
}
});
// 변경사항이 없으면 스킵
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,
});
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTiming = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTiming === "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;
}
// ========================================
// INSERT/UPDATE 판단 (재설계)
// ========================================
// 판단 기준:
// 1. isCreateModeFlag === true → 무조건 INSERT (복사/등록 모드 보호)
// 2. isCreateModeFlag === false → formData.id 있으면 UPDATE, 없으면 INSERT
// originalData는 INSERT/UPDATE 판단에 사용하지 않음 (changedData 계산에만 사용)
// ========================================
let isCreateMode: boolean;
if (isCreateModeFlag) {
// 이벤트에서 명시적으로 INSERT 모드로 지정됨 (등록/복사)
isCreateMode = true;
} else {
// 수정 모드: formData에 id가 있으면 UPDATE, 없으면 INSERT
isCreateMode = !formData.id;
}
console.log("[EditModal] 저장 모드 판단:", {
isCreateMode,
isCreateModeFlag,
formDataId: formData.id,
originalDataLength: Object.keys(originalData).length,
tableName: screenData.screenInfo.tableName,
});
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] 채번 규칙 할당 시작, formData:", {
material: formData.material,
allKeys: Object.keys(formData),
});
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
// 🚀 Promise.all로 병렬 처리 (여러 채번 필드가 있을 경우 성능 향상)
const allocationPromises = Object.entries(fieldsWithNumbering).map(
async ([fieldName, ruleId]) => {
const userInputCode = dataToSave[fieldName] as string;
console.log(`🔄 [EditModal] ${fieldName} 필드에 대해 allocateCode 호출: ${ruleId}`);
try {
const allocateResult = await allocateNumberingCode(ruleId, userInputCode, formData);
if (allocateResult.success && allocateResult.data?.generatedCode) {
return { fieldName, success: true, code: allocateResult.data.generatedCode };
} else {
console.warn(`⚠️ [EditModal] ${fieldName} 코드 할당 실패:`, allocateResult.error);
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
}
} catch (allocateError) {
console.error(`❌ [EditModal] ${fieldName} 코드 할당 오류:`, allocateError);
return { fieldName, success: false, hasExistingValue: !!(dataToSave[fieldName]) };
}
}
);
const allocationResults = await Promise.all(allocationPromises);
// 결과 처리
const failedFields: string[] = [];
for (const result of allocationResults) {
if (result.success && result.code) {
console.log(`✅ [EditModal] ${result.fieldName} 새 코드 할당: ${result.code}`);
dataToSave[result.fieldName] = result.code;
} else if (!result.hasExistingValue) {
failedFields.push(result.fieldName);
}
}
// 채번 규칙 할당 실패 시 저장 중단
if (failedFields.length > 0) {
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];
}
}
}
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
// 🔧 단, 다중 선택 배열은 쉼표 구분 문자열로 변환하여 저장
const masterDataToSave: Record<string, any> = {};
Object.entries(dataToSave).forEach(([key, value]) => {
if (!Array.isArray(value)) {
masterDataToSave[key] = value;
} else {
// 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터)
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
console.log(`🔄 [EditModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
} else {
// 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효)
const isValidValue = (v: any): boolean => {
if (typeof v === "number" && !isNaN(v)) return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
};
// 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링)
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter(isValidValue);
const stringValue = validValues.join(",");
console.log(`🔧 [EditModal CREATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue });
masterDataToSave[key] = stringValue;
}
}
});
console.log("[EditModal] 최종 저장 데이터:", masterDataToSave);
const response = await dynamicFormApi.saveFormData({
screenId: modalState.screenId!,
tableName: screenData.screenInfo.tableName,
data: masterDataToSave,
});
if (response.success) {
const masterRecordId = response.data?.id || formData.id;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
window.dispatchEvent(
new CustomEvent("repeaterSave", {
detail: {
parentId: masterRecordId,
masterRecordId,
mainFormData: formData,
tableName: screenData.screenInfo.tableName,
},
}),
);
console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName });
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 });
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTimingInsert = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTimingInsert === "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 || "생성에 실패했습니다.");
2025-10-30 12:08:58 +09:00
}
} else {
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
2025-09-18 18:49:30 +09:00
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
formDataKeys: Object.keys(formData),
});
toast.error("수정할 레코드의 ID를 찾을 수 없습니다.");
return;
}
2025-09-18 18:49:30 +09:00
// 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
const dataToSave: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
if (Array.isArray(value)) {
const isRepeaterData = value.length > 0 &&
typeof value[0] === "object" &&
value[0] !== null &&
("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]);
if (isRepeaterData) {
// 리피터 데이터는 제외 (별도 저장)
return;
}
// 다중 선택 배열 → 쉼표 구분 문자열
const validValues = value
.map((v: any) => typeof v === "number" ? String(v) : v)
.filter((v: any) => {
if (typeof v === "number") return true;
if (typeof v !== "string") return false;
if (!v || v.trim() === "") return false;
if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false;
return true;
});
dataToSave[key] = validValues.join(",");
} else {
dataToSave[key] = value;
}
});
console.log("[EditModal] UPDATE(PUT) 실행:", {
recordId,
fieldCount: Object.keys(dataToSave).length,
tableName: screenData.screenInfo.tableName,
});
const response = await dynamicFormApi.updateFormData(recordId, {
tableName: screenData.screenInfo.tableName,
data: dataToSave,
});
2025-09-18 18:49:30 +09:00
if (response.success) {
toast.success("데이터가 수정되었습니다.");
2025-10-30 12:08:58 +09:00
// 부모 컴포넌트의 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 });
// 🔧 executionTiming 체크: dataflowTiming 또는 flowConfig.executionTiming 또는 flowControls 확인
const flowTimingUpdate = controlConfig?.dataflowTiming
|| controlConfig?.dataflowConfig?.flowConfig?.executionTiming
|| (controlConfig?.dataflowConfig?.flowControls?.length > 0 ? "after" : null);
if (controlConfig?.enableDataflowControl && flowTimingUpdate === "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("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다.");
}
2025-10-30 12:08:58 +09:00
handleClose();
} else {
throw new Error(response.message || "수정에 실패했습니다.");
}
2025-10-30 12:08:58 +09:00
}
} catch (error: any) {
console.error("❌ 수정 실패:", error);
toast.error(error.message || "데이터 수정 중 오류가 발생했습니다.");
2025-09-18 18:49:30 +09:00
}
};
// 모달 크기 설정 - 화면관리 설정 크기 + 헤더
2025-10-30 12:08:58 +09:00
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 사용
2025-10-30 12:08:58 +09:00
};
2025-09-18 18:49:30 +09:00
}
2025-10-30 12:08:58 +09:00
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
2025-12-10 17:41:41 +09:00
// 실제 모달 크기 = 컨텐츠 + 헤더 + gap + padding + 라벨 공간
2025-12-05 10:46:10 +09:00
const headerHeight = 52; // DialogHeader (타이틀 + border-b + py-3)
const dialogGap = 16; // DialogContent gap-4
const extraPadding = 24; // 추가 여백 (안전 마진)
2025-12-10 17:41:41 +09:00
const labelSpace = 30; // 입력 필드 위 라벨 공간 (-top-6 = 24px + 여유)
2025-12-05 10:46:10 +09:00
2025-12-10 17:41:41 +09:00
const totalHeight = screenDimensions.height + headerHeight + dialogGap + extraPadding + labelSpace;
2025-10-30 12:08:58 +09:00
return {
className: "overflow-hidden p-0",
style: {
2025-12-05 10:46:10 +09:00
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 좌우 패딩 추가
2025-10-30 12:08:58 +09:00
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw",
maxHeight: "95vh",
},
};
2025-09-18 18:49:30 +09:00
};
2025-10-30 12:08:58 +09:00
const modalStyle = getModalStyle();
2025-09-18 18:49:30 +09:00
return (
2025-12-05 10:46:10 +09:00
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent className={`${modalStyle.className} ${className || ""} max-w-none`} style={modalStyle.style}>
2025-12-05 10:46:10 +09:00
<DialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
2025-12-05 10:46:10 +09:00
<DialogTitle className="text-base">{modalState.title || "데이터 수정"}</DialogTitle>
{modalState.description && !loading && (
2025-12-05 10:46:10 +09:00
<DialogDescription className="text-muted-foreground text-xs">{modalState.description}</DialogDescription>
)}
{loading && (
2025-12-05 10:46:10 +09:00
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</div>
2025-12-05 10:46:10 +09:00
</DialogHeader>
2025-10-30 12:03:50 +09:00
<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">
2025-09-18 18:49:30 +09:00
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
2025-10-30 12:03:50 +09:00
<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>
2025-09-18 18:49:30 +09:00
</div>
</div>
2025-10-30 12:08:58 +09:00
) : screenData ? (
2025-09-18 18:49:30 +09:00
<div
className="relative bg-white"
style={{
2025-10-30 12:08:58 +09:00
width: screenDimensions?.width || 800,
// 🆕 조건부 레이어가 활성화되면 높이 자동 확장
height: (() => {
const baseHeight = (screenDimensions?.height || 600) + 30;
if (activeConditionalComponents.length > 0) {
// 조건부 레이어 컴포넌트 중 가장 아래 위치 계산
const offsetY = screenDimensions?.offsetY || 0;
let maxBottom = 0;
activeConditionalComponents.forEach((comp) => {
const y = parseFloat(comp.position?.y?.toString() || "0") - offsetY + 30;
const h = parseFloat(comp.size?.height?.toString() || "40");
maxBottom = Math.max(maxBottom, y + h);
});
return Math.max(baseHeight, maxBottom + 20); // 20px 여백
}
return baseHeight;
})(),
2025-10-30 12:08:58 +09:00
transformOrigin: "center center",
maxWidth: "100%",
2025-09-18 18:49:30 +09:00
}}
>
{/* 기본 레이어 컴포넌트 렌더링 */}
2025-10-30 12:08:58 +09:00
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
2025-12-10 17:41:41 +09:00
const labelSpace = 30; // 라벨 공간 (입력 필드 위 -top-6 라벨용)
2025-10-30 12:08:58 +09:00
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
2025-12-10 17:41:41 +09:00
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace, // 라벨 공간 추가
2025-10-30 12:08:58 +09:00
},
};
2025-12-11 13:25:13 +09:00
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
const hasUniversalFormModal = screenData.components.some(
(c) => {
if (c.componentType === "universal-form-modal") return true;
return false;
}
);
const hasTableSectionData = Object.keys(formData).some(k =>
k.startsWith("_tableSection_") || k.startsWith("__tableSection_")
);
const shouldUseEditModalSave = !hasTableSectionData && (groupData.length > 0 || !hasUniversalFormModal);
2025-12-10 18:38:16 +09:00
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
2025-12-10 18:38:16 +09:00
};
2025-10-30 12:08:58 +09:00
return (
<InteractiveScreenViewerDynamic
2025-09-18 18:49:30 +09:00
key={component.id}
2025-10-30 12:08:58 +09:00
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
2025-12-10 18:38:16 +09:00
formData={enrichedFormData}
originalData={originalData}
2025-10-30 12:08:58 +09:00
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
if (Array.isArray(value)) {
setGroupData(value);
} else {
setGroupData((prev) =>
prev.map((item) => ({
...item,
[fieldName]: value,
})),
);
}
} else {
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}
2025-09-18 18:49:30 +09:00
}}
2025-10-30 12:08:58 +09:00
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
menuObjid={modalState.menuObjid}
onSave={shouldUseEditModalSave ? handleSave : undefined}
isInModal={true}
2025-12-11 13:25:13 +09:00
groupedData={groupedDataProp}
disabledFields={["order_no", "partner_id"]}
2025-10-30 12:08:58 +09:00
/>
);
})}
{/* 🆕 조건부 레이어 컴포넌트 렌더링 */}
{activeConditionalComponents.map((component) => {
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const labelSpace = 30;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY + labelSpace,
},
};
const enrichedFormData = {
...(groupData.length > 0 ? groupData[0] : formData),
tableName: screenData.screenInfo?.tableName,
screenId: modalState.screenId,
};
const groupedDataProp = groupData.length > 0 ? groupData : undefined;
return (
<InteractiveScreenViewerDynamic
key={`conditional-${component.id}`}
component={adjustedComponent}
allComponents={[...screenData.components, ...activeConditionalComponents]}
formData={enrichedFormData}
originalData={originalData}
onFormDataChange={(fieldName, value) => {
if (groupData.length > 0) {
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,
}}
menuObjid={modalState.menuObjid}
isInModal={true}
groupedData={groupedDataProp}
/>
);
})}
2025-09-18 18:49:30 +09:00
</div>
) : (
<div className="flex h-full items-center justify-center">
2025-10-30 12:08:58 +09:00
<p className="text-muted-foreground"> .</p>
2025-09-18 18:49:30 +09:00
</div>
)}
</div>
2025-12-05 10:46:10 +09:00
</DialogContent>
</Dialog>
2025-09-18 18:49:30 +09:00
);
};
2025-10-30 12:08:58 +09:00
export default EditModal;