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

719 lines
26 KiB
TypeScript
Raw 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 {
ResizableDialog,
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-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; // 🆕 테이블명 (그룹 조회용)
}
interface EditModalProps {
className?: string;
}
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,
});
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 제거
};
};
// 전역 모달 이벤트 리스너
useEffect(() => {
const handleOpenEditModal = (event: CustomEvent) => {
const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName } = event.detail;
setModalState({
isOpen: true,
screenId,
title,
description: description || "",
modalSize: modalSize || "lg",
editData: editData || {},
onSave,
groupByColumns, // 🆕 그룹핑 컬럼
tableName, // 🆕 테이블명
});
// 편집 데이터로 폼 데이터 초기화
setFormData(editData || {});
setOriginalData(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]);
// 🆕 그룹 데이터 조회 함수
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);
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 () => {
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,
});
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는 자동 생성되므로 제거
// 🆕 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) {
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): any => {
if (val === null || val === undefined || val === "") return null;
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]);
const originalValue = normalizeValue(originalItemData[key]);
// 값이 변경된 경우만 포함
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue}${currentValue}`);
changedData[key] = 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);
}
}
handleClose();
} else {
toast.info("변경된 내용이 없습니다.");
handleClose();
}
return;
}
// 기존 로직: 단일 레코드 수정
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);
}
}
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 사용
};
}
// 화면관리에서 설정한 크기 = 컨텐츠 영역 크기
// 실제 모달 크기 = 컨텐츠 + 헤더
const headerHeight = 60; // DialogHeader
const totalHeight = screenDimensions.height + headerHeight;
return {
className: "overflow-hidden p-0",
style: {
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw",
maxHeight: "95vh",
},
};
};
const modalStyle = getModalStyle();
return (
<ResizableDialog open={modalState.isOpen} onOpenChange={handleClose}>
<ResizableDialogContent
className={`${modalStyle.className} ${className || ""}`}
style={modalStyle.style}
defaultWidth={800}
defaultHeight={600}
minWidth={600}
minHeight={400}
maxWidth={1400}
maxHeight={1000}
modalId={modalState.screenId ? `edit-modal-${modalState.screenId}` : undefined}
userId={user?.userId}
>
<ResizableDialogHeader className="shrink-0 border-b px-4 py-3">
<div className="flex items-center gap-2">
<ResizableDialogTitle className="text-base">{modalState.title || "데이터 수정"}</ResizableDialogTitle>
{modalState.description && !loading && (
<ResizableDialogDescription className="text-muted-foreground text-xs">{modalState.description}</ResizableDialogDescription>
)}
{loading && (
<ResizableDialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</ResizableDialogDescription>
)}
</div>
</ResizableDialogHeader>
<div className="flex flex-1 items-center justify-center overflow-auto">
{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,
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
{groupData.length > 1 && (
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
{groupData.length}
</div>
)}
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
const offsetY = screenDimensions?.offsetY || 0;
const adjustedComponent = {
...component,
position: {
...component.position,
x: parseFloat(component.position?.x?.toString() || "0") - offsetX,
y: parseFloat(component.position?.y?.toString() || "0") - offsetY,
},
};
// 🔍 디버깅: 컴포넌트 렌더링 시점의 groupData 확인
if (component.id === screenData.components[0]?.id) {
console.log("🔍 [EditModal] InteractiveScreenViewerDynamic props:", {
componentId: component.id,
groupDataLength: groupData.length,
groupData: groupData,
formData: groupData.length > 0 ? groupData[0] : formData,
});
}
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={groupData.length > 0 ? groupData[0] : formData}
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,
}}
onSave={handleSave}
isInModal={true}
// 🆕 그룹 데이터를 ModalRepeaterTable에 전달
groupedData={groupData.length > 0 ? groupData : undefined}
// 🆕 수정 모달에서 읽기 전용 필드 지정 (수주번호, 거래처)
disabledFields={["order_no", "partner_id"]}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground"> .</p>
</div>
)}
</div>
</ResizableDialogContent>
</ResizableDialog>
);
};
export default EditModal;