- EditModal, InteractiveScreenViewer, SaveModal 컴포넌트에서 리피터 데이터(배열)를 마스터 저장에서 제외하고, 별도로 저장하는 로직을 추가하였습니다. - 리피터 데이터 저장 이벤트를 발생시켜 UnifiedRepeater 컴포넌트가 이를 리스닝하도록 개선하였습니다. - 각 컴포넌트에서 최종 저장 데이터 로그를 업데이트하여, 저장 과정에서의 데이터 흐름을 명확히 하였습니다. 이로 인해 데이터 저장의 효율성과 리피터 관리의 일관성이 향상되었습니다.
456 lines
17 KiB
TypeScript
456 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect } from "react";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { X, Save, Loader2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
|
|
import { screenApi } from "@/lib/api/screen";
|
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
|
import { ComponentData } from "@/lib/types/screen";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
|
|
interface SaveModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
screenId?: number;
|
|
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
|
initialData?: any; // 수정 모드일 때 기존 데이터
|
|
onSaveSuccess?: () => void; // 저장 성공 시 콜백 (테이블 새로고침용)
|
|
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 스코프용)
|
|
}
|
|
|
|
/**
|
|
* 저장 전용 모달 컴포넌트
|
|
* - 저장 성공 시: 메시지 표시 → 모달 닫기 → 테이블 새로고침
|
|
* - 저장 실패 시: 에러 메시지 표시, 모달 유지
|
|
*/
|
|
export const SaveModal: React.FC<SaveModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
screenId,
|
|
modalSize = "lg",
|
|
initialData,
|
|
onSaveSuccess,
|
|
menuObjid,
|
|
}) => {
|
|
const { user, userName } = useAuth(); // 현재 사용자 정보 가져오기
|
|
const [formData, setFormData] = useState<Record<string, any>>(initialData || {});
|
|
const [originalData, setOriginalData] = useState<Record<string, any>>(initialData || {});
|
|
const [screenData, setScreenData] = useState<any>(null);
|
|
const [components, setComponents] = useState<ComponentData[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// 모달 크기 설정
|
|
const modalSizeClasses = {
|
|
sm: "max-w-md",
|
|
md: "max-w-2xl",
|
|
lg: "max-w-4xl",
|
|
xl: "max-w-6xl",
|
|
full: "max-w-[95vw]",
|
|
};
|
|
|
|
// 화면 데이터 로드
|
|
useEffect(() => {
|
|
const loadScreenData = async () => {
|
|
if (!screenId || !isOpen) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
// 화면 정보 로드
|
|
const screen = await screenApi.getScreen(screenId);
|
|
setScreenData(screen);
|
|
|
|
// 레이아웃 로드
|
|
const layout = await screenApi.getLayout(screenId);
|
|
setComponents(layout.components || []);
|
|
|
|
// initialData가 있으면 폼에 채우기
|
|
if (initialData) {
|
|
setFormData(initialData);
|
|
setOriginalData(initialData);
|
|
}
|
|
} catch (error) {
|
|
console.error("화면 로드 실패:", error);
|
|
toast.error("화면을 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadScreenData();
|
|
}, [screenId, isOpen, initialData]);
|
|
|
|
// closeSaveModal 이벤트 리스너
|
|
useEffect(() => {
|
|
const handleCloseSaveModal = () => {
|
|
console.log("🚪 SaveModal 닫기 이벤트 수신");
|
|
onClose();
|
|
};
|
|
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("closeSaveModal", handleCloseSaveModal);
|
|
}
|
|
|
|
return () => {
|
|
if (typeof window !== "undefined") {
|
|
window.removeEventListener("closeSaveModal", handleCloseSaveModal);
|
|
}
|
|
};
|
|
}, [onClose]);
|
|
|
|
// 필수 항목 검증
|
|
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
|
|
const missingFields: string[] = [];
|
|
|
|
components.forEach((component) => {
|
|
// 컴포넌트의 required 속성 확인 (여러 위치에서 체크)
|
|
const isRequired =
|
|
component.required === true ||
|
|
component.style?.required === true ||
|
|
component.componentConfig?.required === true;
|
|
|
|
const columnName = component.columnName || component.style?.columnName;
|
|
const label = component.label || component.style?.label || columnName;
|
|
|
|
console.log("🔍 필수 항목 검증:", {
|
|
componentId: component.id,
|
|
columnName,
|
|
label,
|
|
isRequired,
|
|
"component.required": component.required,
|
|
"style.required": component.style?.required,
|
|
"componentConfig.required": component.componentConfig?.required,
|
|
value: formData[columnName || ""],
|
|
});
|
|
|
|
if (isRequired && columnName) {
|
|
const value = formData[columnName];
|
|
// 값이 비어있는지 확인 (null, undefined, 빈 문자열, 공백만 있는 문자열)
|
|
if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) {
|
|
missingFields.push(label || columnName);
|
|
}
|
|
}
|
|
});
|
|
|
|
return {
|
|
isValid: missingFields.length === 0,
|
|
missingFields,
|
|
};
|
|
};
|
|
|
|
// 저장 핸들러
|
|
const handleSave = async () => {
|
|
if (!screenData || !screenId) return;
|
|
|
|
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
|
|
if (!user?.userId) {
|
|
toast.error("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
|
|
return;
|
|
}
|
|
|
|
// ✅ 필수 항목 검증
|
|
const validation = validateRequiredFields();
|
|
if (!validation.isValid) {
|
|
toast.error(`필수 항목을 입력해주세요: ${validation.missingFields.join(", ")}`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsSaving(true);
|
|
|
|
// 변경된 데이터만 추출 (수정 모드일 때)
|
|
const changedData: Record<string, any> = {};
|
|
if (initialData) {
|
|
// 수정 모드: 변경된 필드만 전송
|
|
Object.keys(formData).forEach((key) => {
|
|
if (formData[key] !== originalData[key]) {
|
|
changedData[key] = formData[key];
|
|
}
|
|
});
|
|
|
|
// 변경사항이 없으면 저장하지 않음
|
|
if (Object.keys(changedData).length === 0) {
|
|
toast.info("변경된 내용이 없습니다.");
|
|
setIsSaving(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 저장할 데이터 준비
|
|
const dataToSave = initialData ? changedData : formData;
|
|
|
|
// 🆕 자동으로 작성자 정보 추가 (user.userId가 확실히 있음)
|
|
const writerValue = user.userId;
|
|
const companyCodeValue = user.companyCode || "";
|
|
|
|
console.log("👤 현재 사용자 정보:", {
|
|
userId: user.userId,
|
|
userName: userName,
|
|
companyCode: user.companyCode, // ✅ 회사 코드
|
|
formDataWriter: dataToSave.writer, // ✅ 폼에서 입력한 writer 값
|
|
formDataCompanyCode: dataToSave.company_code, // ✅ 폼에서 입력한 company_code 값
|
|
defaultWriterValue: writerValue,
|
|
companyCodeValue, // ✅ 최종 회사 코드 값
|
|
});
|
|
|
|
const dataWithUserInfo = {
|
|
...dataToSave,
|
|
writer: dataToSave.writer || writerValue, // ✅ 입력값 우선, 없으면 userId
|
|
created_by: writerValue, // created_by는 항상 로그인한 사람
|
|
updated_by: writerValue, // updated_by는 항상 로그인한 사람
|
|
company_code: dataToSave.company_code || companyCodeValue, // ✅ 입력값 우선, 없으면 user.companyCode
|
|
};
|
|
|
|
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (UnifiedRepeater가 별도로 저장)
|
|
const masterDataWithUserInfo: Record<string, any> = {};
|
|
Object.entries(dataWithUserInfo).forEach(([key, value]) => {
|
|
if (!Array.isArray(value)) {
|
|
masterDataWithUserInfo[key] = value;
|
|
} else {
|
|
console.log(`🔄 [SaveModal] 리피터 데이터 제외 (별도 저장): ${key}, ${value.length}개 항목`);
|
|
}
|
|
});
|
|
|
|
// 테이블명 결정
|
|
const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
|
|
|
|
const saveData: DynamicFormData = {
|
|
screenId: screenId,
|
|
tableName: tableName,
|
|
data: masterDataWithUserInfo,
|
|
};
|
|
|
|
console.log("💾 저장 요청 데이터:", saveData);
|
|
|
|
// API 호출
|
|
const result = await dynamicFormApi.saveFormData(saveData);
|
|
|
|
if (result.success) {
|
|
const masterRecordId = result.data?.id || dataToSave.id;
|
|
|
|
// 🆕 리피터 데이터 저장 이벤트 발생 (UnifiedRepeater 컴포넌트가 리스닝)
|
|
window.dispatchEvent(
|
|
new CustomEvent("repeaterSave", {
|
|
detail: {
|
|
parentId: masterRecordId,
|
|
masterRecordId,
|
|
mainFormData: dataToSave,
|
|
tableName: tableName,
|
|
},
|
|
}),
|
|
);
|
|
console.log("📋 [SaveModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName });
|
|
|
|
// ✅ 저장 성공
|
|
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
|
|
|
|
// 모달 닫기
|
|
onClose();
|
|
|
|
// 테이블 새로고침 콜백 호출
|
|
if (onSaveSuccess) {
|
|
setTimeout(() => {
|
|
onSaveSuccess();
|
|
}, 300); // 모달 닫힘 애니메이션 후 실행
|
|
}
|
|
} else {
|
|
throw new Error(result.message || "저장에 실패했습니다.");
|
|
}
|
|
} catch (error: any) {
|
|
// ❌ 저장 실패 - 모달은 닫히지 않음
|
|
console.error("저장 실패:", error);
|
|
toast.error(`저장 중 오류가 발생했습니다: ${error.message || "알 수 없는 오류"}`);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
// 동적 크기 계산 (컴포넌트들의 위치 기반)
|
|
const calculateDynamicSize = () => {
|
|
if (!components.length) return { width: 800, height: 600 };
|
|
|
|
const maxX = Math.max(...components.map((c) => {
|
|
const x = c.position?.x || 0;
|
|
const width = typeof c.size?.width === 'number'
|
|
? c.size.width
|
|
: parseInt(String(c.size?.width || 200), 10);
|
|
return x + width;
|
|
}));
|
|
|
|
const maxY = Math.max(...components.map((c) => {
|
|
const y = c.position?.y || 0;
|
|
const height = typeof c.size?.height === 'number'
|
|
? c.size.height
|
|
: parseInt(String(c.size?.height || 40), 10);
|
|
return y + height;
|
|
}));
|
|
|
|
// 컨텐츠 영역 크기 (화면관리 설정 크기)
|
|
const contentWidth = Math.max(maxX, 400);
|
|
const contentHeight = Math.max(maxY, 300);
|
|
|
|
// 실제 모달 크기 = 컨텐츠 + 헤더
|
|
const headerHeight = 60; // DialogHeader
|
|
|
|
return {
|
|
width: contentWidth,
|
|
height: contentHeight + headerHeight, // 헤더 높이 포함
|
|
};
|
|
};
|
|
|
|
const dynamicSize = calculateDynamicSize();
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
|
<DialogContent
|
|
style={{
|
|
width: `${dynamicSize.width}px`,
|
|
height: `${dynamicSize.height}px`, // 화면관리 설정 크기 그대로 사용
|
|
minWidth: "400px",
|
|
minHeight: "300px",
|
|
}}
|
|
className="gap-0 p-0 max-w-none"
|
|
>
|
|
<DialogHeader className="border-b px-6 py-4 flex-shrink-0">
|
|
<div className="flex items-center justify-between">
|
|
<DialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</DialogTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
저장 중...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="h-4 w-4" />
|
|
저장
|
|
</>
|
|
)}
|
|
</Button>
|
|
<Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
<div className="overflow-auto p-6 flex-1">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
|
</div>
|
|
) : screenData && components.length > 0 ? (
|
|
<div
|
|
className="relative bg-white"
|
|
style={{
|
|
width: `${dynamicSize.width}px`,
|
|
height: `${dynamicSize.height}px`,
|
|
minWidth: `${dynamicSize.width}px`,
|
|
minHeight: `${dynamicSize.height}px`,
|
|
}}
|
|
>
|
|
<div className="relative" style={{ width: `${dynamicSize.width}px`, height: `${dynamicSize.height}px` }}>
|
|
{components.map((component, index) => {
|
|
// ✅ 격자 시스템 잔재 제거: size의 픽셀 값만 사용
|
|
const widthPx = typeof component.size?.width === 'number'
|
|
? component.size.width
|
|
: parseInt(String(component.size?.width || 200), 10);
|
|
const heightPx = typeof component.size?.height === 'number'
|
|
? component.size.height
|
|
: parseInt(String(component.size?.height || 40), 10);
|
|
|
|
// 디버깅: 실제 크기 확인
|
|
if (index === 0) {
|
|
console.log('🔍 SaveModal 컴포넌트 크기:', {
|
|
componentId: component.id,
|
|
'size.width (원본)': component.size?.width,
|
|
'size.width 타입': typeof component.size?.width,
|
|
'widthPx (계산)': widthPx,
|
|
'style.width': component.style?.width,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={component.id}
|
|
style={{
|
|
position: "absolute",
|
|
top: component.position?.y || 0,
|
|
left: component.position?.x || 0,
|
|
width: `${widthPx}px`, // ✅ 픽셀 단위 강제
|
|
height: `${heightPx}px`, // ✅ 픽셀 단위 강제
|
|
zIndex: component.position?.z || 1000 + index,
|
|
}}
|
|
>
|
|
{component.type === "widget" ? (
|
|
<InteractiveScreenViewer
|
|
component={component}
|
|
allComponents={components}
|
|
formData={formData}
|
|
onFormDataChange={(fieldName, value) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}}
|
|
hideLabel={false}
|
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
|
/>
|
|
) : (
|
|
<DynamicComponentRenderer
|
|
component={{
|
|
...component,
|
|
style: {
|
|
...component.style,
|
|
labelDisplay: true,
|
|
},
|
|
}}
|
|
screenId={screenId}
|
|
tableName={screenData.tableName}
|
|
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 (카테고리 스코프용)
|
|
userId={user?.userId} // ✅ 사용자 ID 전달
|
|
userName={user?.userName} // ✅ 사용자 이름 전달
|
|
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
|
formData={formData}
|
|
originalData={originalData}
|
|
onFormDataChange={(fieldName, value) => {
|
|
console.log("📝 SaveModal - formData 변경:", {
|
|
fieldName,
|
|
value,
|
|
componentType: component.type,
|
|
componentId: component.id,
|
|
});
|
|
setFormData((prev) => {
|
|
const newData = {
|
|
...prev,
|
|
[fieldName]: value,
|
|
};
|
|
console.log("📦 새 formData:", newData);
|
|
return newData;
|
|
});
|
|
}}
|
|
mode="edit"
|
|
isInModal={true}
|
|
isInteractive={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-muted-foreground py-12 text-center">화면에 컴포넌트가 없습니다.</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|