Files
vexplor/frontend/components/screen/SaveModal.tsx
kjs d56e46b17c refactor: Update TabBar and EditModal components for improved styling and validation
- Removed unnecessary box shadow from active tab in TabBar for a cleaner look.
- Updated TabBar background to use the main background color for better consistency.
- Enhanced SaveModal to include validation for required fields, providing user feedback for missing inputs.
- Removed unused master data loading function in EditModal to streamline the component.

These changes improve the overall user interface and ensure that required fields are validated before submission, enhancing user experience.
2026-03-10 15:19:50 +09:00

482 lines
18 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 { showErrorToast } from "@/lib/utils/toastUtils";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { getTableColumns, ColumnTypeInfo } from "@/lib/api/tableManagement";
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 [tableColumnsInfo, setTableColumnsInfo] = useState<ColumnTypeInfo[]>([]);
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 || []);
// 테이블 컬럼 정보 로드 (NOT NULL 필수값 자동 인식용)
const tblName = screen?.tableName || layout.components?.find((c: any) => c.columnName)?.tableName;
if (tblName) {
try {
const colResult = await getTableColumns(tblName);
if (colResult.success && colResult.data?.columns) {
setTableColumnsInfo(colResult.data.columns);
}
} catch (colErr) {
console.warn("테이블 컬럼 정보 로드 실패 (필수값 검증 시 기존 방식 사용):", colErr);
}
}
// initialData가 있으면 폼에 채우기
if (initialData) {
setFormData(initialData);
setOriginalData(initialData);
}
} catch (error) {
console.error("화면 로드 실패:", error);
showErrorToast("화면 구성 정보를 불러오는 데 실패했습니다", error, {
guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요.",
});
} 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]);
// 테이블 타입관리의 NOT NULL 설정 기반으로 필수 여부 판단
const isColumnRequired = (columnName: string): boolean => {
if (!columnName || tableColumnsInfo.length === 0) return false;
const colInfo = tableColumnsInfo.find((c) => c.columnName.toLowerCase() === columnName.toLowerCase());
if (!colInfo) return false;
// is_nullable가 "NO"이면 필수
return colInfo.isNullable === "NO" || colInfo.isNullable === "N";
};
// 필수 항목 검증 (테이블 타입관리 NOT NULL + 기존 required 속성 병합)
const validateRequiredFields = (): { isValid: boolean; missingFields: string[] } => {
const missingFields: string[] = [];
components.forEach((component) => {
const columnName = component.columnName || component.style?.columnName;
const label = component.label || component.style?.label || columnName;
// 기존 required 속성 (화면 디자이너에서 수동 설정한 것)
const manualRequired =
component.required === true ||
component.style?.required === true ||
component.componentConfig?.required === true;
// 테이블 타입관리 NOT NULL 기반 필수 (컬럼 정보가 있을 때만)
const notNullRequired = columnName ? isColumnRequired(columnName) : false;
// 둘 중 하나라도 필수이면 검증
const isRequired = manualRequired || notNullRequired;
if (isRequired && columnName) {
const value = formData[columnName];
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
};
// 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장)
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;
// 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝)
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 {
const errorMsg = result.message || result.error?.message || "저장에 실패했습니다.";
toast.error(errorMsg);
}
} catch (error: any) {
// ❌ 저장 실패 - 모달은 닫히지 않음
console.error("저장 실패:", error);
showErrorToast("데이터 저장에 실패했습니다", error, {
guidance: "입력 값을 확인하고 다시 시도해 주세요.",
});
} 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="max-w-none gap-0 p-0"
>
<DialogHeader className="flex-shrink-0 border-b px-6 py-4">
<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="flex-1 overflow-auto p-6">
{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}
tableColumns={tableColumnsInfo as any}
/>
) : (
<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>
);
};