306 lines
9.9 KiB
TypeScript
306 lines
9.9 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";
|
|
|
|
interface SaveModalProps {
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
screenId?: number;
|
|
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
|
initialData?: any; // 수정 모드일 때 기존 데이터
|
|
onSaveSuccess?: () => void; // 저장 성공 시 콜백 (테이블 새로고침용)
|
|
}
|
|
|
|
/**
|
|
* 저장 전용 모달 컴포넌트
|
|
* - 저장 성공 시: 메시지 표시 → 모달 닫기 → 테이블 새로고침
|
|
* - 저장 실패 시: 에러 메시지 표시, 모달 유지
|
|
*/
|
|
export const SaveModal: React.FC<SaveModalProps> = ({
|
|
isOpen,
|
|
onClose,
|
|
screenId,
|
|
modalSize = "lg",
|
|
initialData,
|
|
onSaveSuccess,
|
|
}) => {
|
|
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 handleSave = async () => {
|
|
if (!screenData || !screenId) 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;
|
|
|
|
// 테이블명 결정
|
|
const tableName =
|
|
screenData.tableName ||
|
|
components.find((c) => c.columnName)?.tableName ||
|
|
"dynamic_form_data";
|
|
|
|
const saveData: DynamicFormData = {
|
|
screenId: screenId,
|
|
tableName: tableName,
|
|
data: dataToSave,
|
|
};
|
|
|
|
console.log("💾 저장 요청 데이터:", saveData);
|
|
|
|
// API 호출
|
|
const result = await dynamicFormApi.saveFormData(saveData);
|
|
|
|
if (result.success) {
|
|
// ✅ 저장 성공
|
|
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) => (c.position?.x || 0) + (c.size?.width || 200)));
|
|
const maxY = Math.max(...components.map((c) => (c.position?.y || 0) + (c.size?.height || 40)));
|
|
|
|
const padding = 40;
|
|
return {
|
|
width: Math.max(maxX + padding, 400),
|
|
height: Math.max(maxY + padding, 300),
|
|
};
|
|
};
|
|
|
|
const dynamicSize = calculateDynamicSize();
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
|
|
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] p-0 gap-0`}>
|
|
<DialogHeader className="px-6 py-4 border-b">
|
|
<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">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : screenData && components.length > 0 ? (
|
|
<div
|
|
className="relative bg-white"
|
|
style={{
|
|
width: dynamicSize.width,
|
|
height: dynamicSize.height,
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
<div className="relative" style={{ minHeight: "300px" }}>
|
|
{components.map((component, index) => (
|
|
<div
|
|
key={component.id}
|
|
style={{
|
|
position: "absolute",
|
|
top: component.position?.y || 0,
|
|
left: component.position?.x || 0,
|
|
width: component.size?.width || 200,
|
|
height: component.size?.height || 40,
|
|
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}
|
|
/>
|
|
) : (
|
|
<DynamicComponentRenderer
|
|
component={{
|
|
...component,
|
|
style: {
|
|
...component.style,
|
|
labelDisplay: true,
|
|
},
|
|
}}
|
|
screenId={screenId}
|
|
tableName={screenData.tableName}
|
|
formData={formData}
|
|
originalData={originalData}
|
|
onFormDataChange={(fieldName, value) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
}}
|
|
mode={initialData ? "edit" : "create"}
|
|
isInModal={true}
|
|
isInteractive={true}
|
|
/>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="py-12 text-center text-muted-foreground">
|
|
화면에 컴포넌트가 없습니다.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|