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

373 lines
14 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect } from "react";
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, ResizableDialogTitle } from "@/components/ui/resizable-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";
2025-10-29 11:26:00 +09:00
import { useAuth } from "@/hooks/useAuth";
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,
}) => {
2025-10-29 11:26:00 +09:00
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();
};
2025-10-29 11:26:00 +09:00
if (typeof window !== "undefined") {
window.addEventListener("closeSaveModal", handleCloseSaveModal);
}
return () => {
2025-10-29 11:26:00 +09:00
if (typeof window !== "undefined") {
window.removeEventListener("closeSaveModal", handleCloseSaveModal);
}
};
}, [onClose]);
// 저장 핸들러
const handleSave = async () => {
if (!screenData || !screenId) return;
// ✅ 사용자 정보가 로드되지 않았으면 저장 불가
if (!user?.userId) {
toast.error("사용자 정보를 불러오는 중입니다. 잠시 후 다시 시도해주세요.");
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 || "";
2025-10-29 11:26:00 +09:00
console.log("👤 현재 사용자 정보:", {
userId: user.userId,
2025-10-29 11:26:00 +09:00
userName: userName,
companyCode: user.companyCode, // ✅ 회사 코드
formDataWriter: dataToSave.writer, // ✅ 폼에서 입력한 writer 값
formDataCompanyCode: dataToSave.company_code, // ✅ 폼에서 입력한 company_code 값
defaultWriterValue: writerValue,
companyCodeValue, // ✅ 최종 회사 코드 값
2025-10-29 11:26:00 +09:00
});
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
2025-10-29 11:26:00 +09:00
};
// 테이블명 결정
2025-10-29 11:26:00 +09:00
const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
const saveData: DynamicFormData = {
screenId: screenId,
tableName: tableName,
2025-10-29 11:26:00 +09:00
data: dataWithUserInfo,
};
console.log("💾 저장 요청 데이터:", saveData);
// API 호출
const result = await dynamicFormApi.saveFormData(saveData);
if (result.success) {
// ✅ 저장 성공
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
2025-10-29 11:26:00 +09:00
// 모달 닫기
onClose();
2025-10-29 11:26:00 +09:00
// 테이블 새로고침 콜백 호출
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 };
2025-11-10 09:33:29 +09:00
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 padding = 40;
return {
width: Math.max(maxX + padding, 400),
height: Math.max(maxY + padding, 300),
};
};
const dynamicSize = calculateDynamicSize();
return (
<ResizableDialog open={isOpen} onOpenChange={(open) => !isSaving && !open && onClose()}>
2025-11-10 09:33:29 +09:00
<ResizableDialogContent
modalId={`save-modal-${screenId}`}
defaultWidth={dynamicSize.width + 48}
defaultHeight={dynamicSize.height + 120}
minWidth={400}
minHeight={300}
className="gap-0 p-0"
>
<ResizableDialogHeader className="border-b px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between">
2025-11-05 16:36:32 +09:00
<ResizableDialogTitle className="text-lg font-semibold">{initialData ? "데이터 수정" : "데이터 등록"}</ResizableDialogTitle>
<div className="flex items-center gap-2">
2025-10-29 11:26:00 +09:00
<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>
2025-10-29 11:26:00 +09:00
<Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
</div>
</div>
</ResizableDialogHeader>
2025-11-10 09:33:29 +09:00
<div className="overflow-auto p-6 flex-1">
{loading ? (
<div className="flex items-center justify-center py-12">
2025-10-29 11:26:00 +09:00
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : screenData && components.length > 0 ? (
<div
2025-11-10 09:33:29 +09:00
className="relative bg-white"
style={{
2025-11-10 09:33:29 +09:00
width: `${dynamicSize.width}px`,
height: `${dynamicSize.height}px`,
minWidth: `${dynamicSize.width}px`,
minHeight: `${dynamicSize.height}px`,
}}
>
2025-11-10 09:33:29 +09:00
<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,
2025-11-10 09:33:29 +09:00
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}
/>
) : (
<DynamicComponentRenderer
component={{
...component,
style: {
...component.style,
labelDisplay: true,
},
}}
screenId={screenId}
tableName={screenData.tableName}
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>
2025-11-10 09:33:29 +09:00
);
})}
</div>
</div>
) : (
2025-10-29 11:26:00 +09:00
<div className="text-muted-foreground py-12 text-center"> .</div>
)}
</div>
</ResizableDialogContent>
</ResizableDialog>
);
};