모달창 올리기

This commit is contained in:
kjs
2025-10-29 11:26:00 +09:00
parent eeae338cd4
commit efdef36cda
21 changed files with 727 additions and 728 deletions

View File

@@ -35,6 +35,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const [screenDimensions, setScreenDimensions] = useState<{
width: number;
height: number;
offsetX?: number;
offsetY?: number;
} | null>(null);
// 폼 데이터 상태 추가
@@ -42,11 +44,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 화면의 실제 크기 계산 함수
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 = 0;
let maxY = 0;
let maxX = -Infinity;
let maxY = -Infinity;
components.forEach((component) => {
const x = parseFloat(component.position?.x?.toString() || "0");
@@ -60,17 +71,22 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
maxY = Math.max(maxY, y + height);
});
// 컨텐츠 실제 크기 + 넉넉한 여백 (양쪽 각 64px)
// 실제 컨텐츠 크기 계산
const contentWidth = maxX - minX;
const contentHeight = maxY - minY;
const padding = 128; // 좌우 또는 상하 합계 여백
const finalWidth = Math.max(contentWidth + padding, 400); // 최소 400px
const finalHeight = Math.max(contentHeight + padding, 300); // 최소 300px
// 적절한 여백 추가
const paddingX = 40;
const paddingY = 40;
const finalWidth = Math.max(contentWidth + paddingX, 400);
const finalHeight = Math.max(contentHeight + paddingY, 300);
return {
width: Math.min(finalWidth, window.innerWidth * 0.98),
height: Math.min(finalHeight, window.innerHeight * 0.95),
width: Math.min(finalWidth, window.innerWidth * 0.95),
height: Math.min(finalHeight, window.innerHeight * 0.9),
offsetX: Math.max(0, minX - paddingX / 2), // 좌측 여백 고려
offsetY: Math.max(0, minY - paddingY / 2), // 상단 여백 고려
};
};
@@ -172,20 +188,20 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const getModalStyle = () => {
if (!screenDimensions) {
return {
className: "w-fit min-w-[400px] max-w-4xl max-h-[80vh] overflow-hidden",
className: "w-fit min-w-[400px] max-w-4xl max-h-[90vh] overflow-hidden p-0",
style: {},
};
}
// 헤더 높이만 고려 (패딩 제거)
const headerHeight = 73; // DialogHeader 실제 높이 (border-b px-6 py-4 포함)
// 헤더 높이를 최소화 (제목 영역만)
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`, // 헤더 + 화면 높이
width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`,
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`,
maxWidth: "98vw",
maxHeight: "95vh",
},
@@ -197,12 +213,14 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return (
<Dialog open={modalState.isOpen} onOpenChange={handleClose}>
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{modalState.title}</DialogTitle>
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
<DialogHeader className="shrink-0 border-b px-4 py-3">
<DialogTitle className="text-base">{modalState.title}</DialogTitle>
{loading && (
<DialogDescription className="text-xs">{loading ? "화면을 불러오는 중입니다..." : ""}</DialogDescription>
)}
</DialogHeader>
<div className="flex-1 flex items-center justify-center overflow-hidden">
<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">
@@ -216,35 +234,50 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
transformOrigin: 'center center',
maxWidth: '100%',
maxHeight: '100%',
transformOrigin: "center center",
maxWidth: "100%",
maxHeight: "100%",
}}
>
{screenData.components.map((component) => (
<InteractiveScreenViewerDynamic
key={component.id}
component={component}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
))}
{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,
},
};
return (
<InteractiveScreenViewerDynamic
key={component.id}
component={adjustedComponent}
allComponents={screenData.components}
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}
screenInfo={{
id: modalState.screenId!,
tableName: screenData.screenInfo?.tableName,
}}
/>
);
})}
</div>
) : (
<div className="flex h-full items-center justify-center">

View File

@@ -13,12 +13,14 @@ import { useReactFlow } from "reactflow";
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
import type { FlowValidation } from "@/lib/utils/flowValidation";
import { useToast } from "@/hooks/use-toast";
interface FlowToolbarProps {
validations?: FlowValidation[];
}
export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const { toast } = useToast();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const {
flowName,
@@ -56,9 +58,17 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
const performSave = async () => {
const result = await saveFlow();
if (result.success) {
alert(`${result.message}\nFlow ID: ${result.flowId}`);
toast({
title: "✅ 플로우 저장 완료",
description: `${result.message}\nFlow ID: ${result.flowId}`,
variant: "default",
});
} else {
alert(`❌ 저장 실패\n\n${result.message}`);
toast({
title: "❌ 저장 실패",
description: result.message,
variant: "destructive",
});
}
setShowSaveDialog(false);
};
@@ -72,18 +82,30 @@ export function FlowToolbar({ validations = [] }: FlowToolbarProps) {
a.download = `${flowName || "flow"}.json`;
a.click();
URL.revokeObjectURL(url);
alert("✅ JSON 파일로 내보내기 완료!");
toast({
title: "✅ 내보내기 완료",
description: "JSON 파일로 저장되었습니다.",
variant: "default",
});
};
const handleDelete = () => {
if (selectedNodes.length === 0) {
alert("삭제할 노드를 선택해주세요.");
toast({
title: "⚠️ 선택된 노드 없음",
description: "삭제할 노드를 선택해주세요.",
variant: "default",
});
return;
}
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
removeNodes(selectedNodes);
alert(`${selectedNodes.length}개 노드가 삭제되었습니다.`);
toast({
title: "✅ 노드 삭제 완료",
description: `${selectedNodes.length}개 노드가 삭제되었습니다.`,
variant: "default",
});
}
};

View File

@@ -18,189 +18,178 @@ interface ValidationNotificationProps {
onClose?: () => void;
}
export const ValidationNotification = memo(
({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const summary = summarizeValidations(validations);
export const ValidationNotification = memo(({ validations, onNodeClick, onClose }: ValidationNotificationProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const summary = summarizeValidations(validations);
if (validations.length === 0) {
return null;
}
if (validations.length === 0) {
return null;
}
const getTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
"parallel-conflict": "병렬 실행 충돌",
"missing-where": "WHERE 조건 누락",
"circular-reference": "순환 참조",
"data-source-mismatch": "데이터 소스 불일치",
"parallel-table-access": "병렬 테이블 접근",
};
return labels[type] || type;
const getTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
"disconnected-node": "연결되지 않은 노드",
"parallel-conflict": "병렬 실행 충돌",
"missing-where": "WHERE 조건 누락",
"circular-reference": "순환 참조",
"data-source-mismatch": "데이터 소스 불일치",
"parallel-table-access": "병렬 테이블 접근",
};
return labels[type] || type;
};
// 타입별로 그룹화
const groupedValidations = validations.reduce((acc, validation) => {
// 타입별로 그룹화
const groupedValidations = validations.reduce(
(acc, validation) => {
if (!acc[validation.type]) {
acc[validation.type] = [];
}
acc[validation.type].push(validation);
return acc;
}, {} as Record<string, FlowValidation[]>);
},
{} as Record<string, FlowValidation[]>,
);
return (
<div className="fixed right-4 top-4 z-50 w-80 animate-in slide-in-from-right-5 duration-300">
return (
<div className="animate-in slide-in-from-right-5 fixed top-4 right-4 z-50 w-80 duration-300">
<div
className={cn(
"rounded-lg border-2 bg-white shadow-2xl",
summary.hasBlockingIssues
? "border-red-500"
: summary.warningCount > 0
? "border-yellow-500"
: "border-blue-500",
)}
>
{/* 헤더 */}
<div
className={cn(
"rounded-lg border-2 bg-white shadow-2xl",
summary.hasBlockingIssues
? "border-red-500"
: summary.warningCount > 0
? "border-yellow-500"
: "border-blue-500"
"flex cursor-pointer items-center justify-between p-3",
summary.hasBlockingIssues ? "bg-red-50" : summary.warningCount > 0 ? "bg-yellow-50" : "bg-blue-50",
)}
onClick={() => setIsExpanded(!isExpanded)}
>
{/* 헤더 */}
<div
className={cn(
"flex cursor-pointer items-center justify-between p-3",
summary.hasBlockingIssues
? "bg-red-50"
: summary.warningCount > 0
? "bg-yellow-50"
: "bg-blue-50"
<div className="flex items-center gap-2">
{summary.hasBlockingIssues ? (
<AlertCircle className="h-5 w-5 text-red-600" />
) : summary.warningCount > 0 ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<Info className="h-5 w-5 text-blue-600" />
)}
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center gap-2">
{summary.hasBlockingIssues ? (
<AlertCircle className="h-5 w-5 text-red-600" />
) : summary.warningCount > 0 ? (
<AlertTriangle className="h-5 w-5 text-yellow-600" />
) : (
<Info className="h-5 w-5 text-blue-600" />
)}
<span className="text-sm font-semibold text-gray-900">
</span>
<div className="flex items-center gap-1">
{summary.errorCount > 0 && (
<Badge variant="destructive" className="h-5 text-[10px]">
{summary.errorCount}
</Badge>
)}
{summary.warningCount > 0 && (
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">
{summary.warningCount}
</Badge>
)}
{summary.infoCount > 0 && (
<Badge variant="secondary" className="h-5 text-[10px]">
{summary.infoCount}
</Badge>
)}
</div>
</div>
<span className="text-sm font-semibold text-gray-900"> </span>
<div className="flex items-center gap-1">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
{summary.errorCount > 0 && (
<Badge variant="destructive" className="h-5 text-[10px]">
{summary.errorCount}
</Badge>
)}
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="h-6 w-6 p-0 hover:bg-white/50"
>
<X className="h-3.5 w-3.5" />
</Button>
{summary.warningCount > 0 && (
<Badge className="h-5 bg-yellow-500 text-[10px] hover:bg-yellow-600">{summary.warningCount}</Badge>
)}
{summary.infoCount > 0 && (
<Badge variant="secondary" className="h-5 text-[10px]">
{summary.infoCount}
</Badge>
)}
</div>
</div>
{/* 확장된 내용 */}
{isExpanded && (
<div className="max-h-[60vh] overflow-y-auto border-t">
<div className="p-2 space-y-2">
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
const firstValidation = typeValidations[0];
const Icon =
firstValidation.severity === "error"
? AlertCircle
: firstValidation.severity === "warning"
? AlertTriangle
: Info;
return (
<div key={type}>
{/* 타입 헤더 */}
<div
className={cn(
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
firstValidation.severity === "error"
? "bg-red-100 text-red-700"
: firstValidation.severity === "warning"
? "bg-yellow-100 text-yellow-700"
: "bg-blue-100 text-blue-700"
)}
>
<Icon className="h-3 w-3" />
{getTypeLabel(type)}
<span className="ml-auto">
{typeValidations.length}
</span>
</div>
{/* 검증 항목들 */}
<div className="space-y-1 pl-5">
{typeValidations.map((validation, index) => (
<div
key={index}
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
onClick={() => onNodeClick?.(validation.nodeId)}
>
<p className="text-gray-700 leading-relaxed">
{validation.message}
</p>
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
<div className="mt-1 text-[10px] text-gray-500">
: {validation.affectedNodes.length}
</div>
)}
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 요약 메시지 (닫혀있을 때) */}
{!isExpanded && (
<div className="border-t px-3 py-2">
<p className="text-xs text-gray-600">
{summary.hasBlockingIssues
? "⛔ 오류를 해결해야 저장할 수 있습니다"
: summary.warningCount > 0
? "⚠️ 경고 사항을 확인하세요"
: " 정보를 확인하세요"}
</p>
</div>
)}
<div className="flex items-center gap-1">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-400" />
) : (
<ChevronDown className="h-4 w-4 text-gray-400" />
)}
{onClose && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="h-6 w-6 p-0 hover:bg-white/50"
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
{/* 확장된 내용 */}
{isExpanded && (
<div className="max-h-[60vh] overflow-y-auto border-t">
<div className="space-y-2 p-2">
{Object.entries(groupedValidations).map(([type, typeValidations]) => {
const firstValidation = typeValidations[0];
const Icon =
firstValidation.severity === "error"
? AlertCircle
: firstValidation.severity === "warning"
? AlertTriangle
: Info;
return (
<div key={type}>
{/* 타입 헤더 */}
<div
className={cn(
"mb-1 flex items-center gap-2 rounded-md px-2 py-1 text-xs font-medium",
firstValidation.severity === "error"
? "bg-red-100 text-red-700"
: firstValidation.severity === "warning"
? "bg-yellow-100 text-yellow-700"
: "bg-blue-100 text-blue-700",
)}
>
<Icon className="h-3 w-3" />
{getTypeLabel(type)}
<span className="ml-auto">{typeValidations.length}</span>
</div>
{/* 검증 항목들 */}
<div className="space-y-1 pl-5">
{typeValidations.map((validation, index) => (
<div
key={index}
className="group cursor-pointer rounded-md border border-gray-200 bg-gray-50 p-2 text-xs transition-all hover:border-gray-300 hover:bg-white hover:shadow-sm"
onClick={() => onNodeClick?.(validation.nodeId)}
>
<p className="leading-relaxed text-gray-700">{validation.message}</p>
{validation.affectedNodes && validation.affectedNodes.length > 1 && (
<div className="mt-1 text-[10px] text-gray-500">
: {validation.affectedNodes.length}
</div>
)}
<div className="mt-1 text-[10px] text-blue-600 opacity-0 transition-opacity group-hover:opacity-100">
</div>
</div>
))}
</div>
</div>
);
})}
</div>
</div>
)}
{/* 요약 메시지 (닫혀있을 때) */}
{!isExpanded && (
<div className="border-t px-3 py-2">
<p className="text-xs text-gray-600">
{summary.hasBlockingIssues
? "⛔ 오류를 해결해야 저장할 수 있습니다"
: summary.warningCount > 0
? "⚠️ 경고 사항을 확인하세요"
: " 정보를 확인하세요"}
</p>
</div>
)}
</div>
);
}
);
</div>
);
});
ValidationNotification.displayName = "ValidationNotification";

View File

@@ -1359,13 +1359,28 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
allComponents.find(c => c.columnName)?.tableName ||
"dynamic_form_data"; // 기본값
// 🆕 자동으로 작성자 정보 추가
const writerValue = user?.userId || userName || "unknown";
console.log("👤 현재 사용자 정보:", {
userId: user?.userId,
userName: userName,
writerValue: writerValue,
});
const dataWithUserInfo = {
...mappedData,
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
created_by: writerValue,
updated_by: writerValue,
};
const saveData: DynamicFormData = {
screenId: screenInfo.id,
tableName: tableName,
data: mappedData,
data: dataWithUserInfo,
};
// console.log("🚀 API 저장 요청:", saveData);
console.log("🚀 API 저장 요청:", saveData);
const result = await dynamicFormApi.saveFormData(saveData);
@@ -1859,12 +1874,12 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
setPopupScreen(null);
setPopupFormData({}); // 팝업 닫을 때 formData도 초기화
}}>
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden">
<DialogHeader>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden p-0">
<DialogHeader className="px-6 pt-4 pb-2">
<DialogTitle>{popupScreen?.title || "상세 정보"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto max-h-[60vh] p-2">
<div className="overflow-y-auto px-6 pb-6" style={{ maxHeight: "calc(90vh - 80px)" }}>
{popupLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-gray-500"> ...</div>

View File

@@ -180,16 +180,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 버튼 컴포넌트 또는 위젯이 아닌 경우 DynamicComponentRenderer 사용
if (comp.type !== "widget") {
console.log("🎯 InteractiveScreenViewer - DynamicComponentRenderer 사용:", {
componentId: comp.id,
componentType: comp.type,
isButton: isButtonComponent(comp),
componentConfig: comp.componentConfig,
style: comp.style,
size: comp.size,
position: comp.position,
});
return (
<DynamicComponentRenderer
component={comp}
@@ -211,7 +201,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
setFlowSelectedStepId(stepId);
}}
onRefresh={() => {
console.log("🔄 버튼에서 테이블 새로고침 요청됨");
// 테이블 컴포넌트는 자체적으로 loadData 호출
}}
onClose={() => {

View File

@@ -38,6 +38,9 @@ interface RealtimePreviewProps {
// 버튼 액션을 위한 props
screenId?: number;
tableName?: string;
userId?: string; // 🆕 현재 사용자 ID
userName?: string; // 🆕 현재 사용자 이름
companyCode?: string; // 🆕 현재 사용자의 회사 코드
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
flowSelectedData?: any[];
@@ -96,6 +99,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onConfigChange,
screenId,
tableName,
userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드
selectedRowsData,
onSelectedRowsChange,
flowSelectedData,
@@ -291,6 +297,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onConfigChange={onConfigChange}
screenId={screenId}
tableName={tableName}
userId={userId}
userName={userName}
companyCode={companyCode}
selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange}
flowSelectedData={flowSelectedData}

View File

@@ -10,6 +10,7 @@ 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;
@@ -33,6 +34,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
initialData,
onSaveSuccess,
}) => {
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);
@@ -88,13 +90,13 @@ export const SaveModal: React.FC<SaveModalProps> = ({
onClose();
};
if (typeof window !== 'undefined') {
window.addEventListener('closeSaveModal', handleCloseSaveModal);
if (typeof window !== "undefined") {
window.addEventListener("closeSaveModal", handleCloseSaveModal);
}
return () => {
if (typeof window !== 'undefined') {
window.removeEventListener('closeSaveModal', handleCloseSaveModal);
if (typeof window !== "undefined") {
window.removeEventListener("closeSaveModal", handleCloseSaveModal);
}
};
}, [onClose]);
@@ -127,16 +129,28 @@ export const SaveModal: React.FC<SaveModalProps> = ({
// 저장할 데이터 준비
const dataToSave = initialData ? changedData : formData;
// 🆕 자동으로 작성자 정보 추가
const writerValue = user?.userId || userName || "unknown";
console.log("👤 현재 사용자 정보:", {
userId: user?.userId,
userName: userName,
writerValue: writerValue,
});
const dataWithUserInfo = {
...dataToSave,
writer: writerValue, // 테이블 생성 시 자동 생성되는 컬럼
created_by: writerValue,
updated_by: writerValue,
};
// 테이블명 결정
const tableName =
screenData.tableName ||
components.find((c) => c.columnName)?.tableName ||
"dynamic_form_data";
const tableName = screenData.tableName || components.find((c) => c.columnName)?.tableName || "dynamic_form_data";
const saveData: DynamicFormData = {
screenId: screenId,
tableName: tableName,
data: dataToSave,
data: dataWithUserInfo,
};
console.log("💾 저장 요청 데이터:", saveData);
@@ -147,10 +161,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
if (result.success) {
// ✅ 저장 성공
toast.success(initialData ? "수정되었습니다!" : "저장되었습니다!");
// 모달 닫기
onClose();
// 테이블 새로고침 콜백 호출
if (onSaveSuccess) {
setTimeout(() => {
@@ -187,19 +201,12 @@ export const SaveModal: React.FC<SaveModalProps> = ({
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">
<DialogContent className={`${modalSizeClasses[modalSize]} max-h-[90vh] gap-0 p-0`}>
<DialogHeader className="border-b px-6 py-4">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-semibold">
{initialData ? "데이터 수정" : "데이터 등록"}
</DialogTitle>
<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"
>
<Button onClick={handleSave} disabled={isSaving} size="sm" className="gap-2">
{isSaving ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
@@ -212,12 +219,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
</>
)}
</Button>
<Button
onClick={onClose}
disabled={isSaving}
variant="ghost"
size="sm"
>
<Button onClick={onClose} disabled={isSaving} variant="ghost" size="sm">
<X className="h-4 w-4" />
</Button>
</div>
@@ -227,7 +229,7 @@ export const SaveModal: React.FC<SaveModalProps> = ({
<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" />
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
) : screenData && components.length > 0 ? (
<div
@@ -293,13 +295,10 @@ export const SaveModal: React.FC<SaveModalProps> = ({
</div>
</div>
) : (
<div className="py-12 text-center text-muted-foreground">
.
</div>
<div className="text-muted-foreground py-12 text-center"> .</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -403,6 +403,33 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const targetComponent = prevLayout.components.find((comp) => comp.id === componentId);
const isLayoutComponent = targetComponent?.type === "layout";
// 🆕 그룹 설정 변경 시 같은 그룹의 모든 버튼에 일괄 적용
const isGroupSetting = path === "webTypeConfig.flowVisibilityConfig.groupAlign";
let affectedComponents: string[] = [componentId]; // 기본적으로 현재 컴포넌트만
if (isGroupSetting && targetComponent) {
const flowConfig = (targetComponent as any).webTypeConfig?.flowVisibilityConfig;
const currentGroupId = flowConfig?.groupId;
if (currentGroupId) {
// 같은 그룹의 모든 버튼 찾기
affectedComponents = prevLayout.components
.filter((comp) => {
const compConfig = (comp as any).webTypeConfig?.flowVisibilityConfig;
return compConfig?.groupId === currentGroupId && compConfig?.enabled;
})
.map((comp) => comp.id);
console.log("🔄 그룹 설정 일괄 적용:", {
groupId: currentGroupId,
setting: path.split(".").pop(),
value,
affectedButtons: affectedComponents,
});
}
}
// 레이아웃 컴포넌트의 위치가 변경되는 경우 존에 속한 컴포넌트들도 함께 이동
const positionDelta = { x: 0, y: 0 };
if (isLayoutComponent && (path === "position.x" || path === "position.y" || path === "position")) {
@@ -431,7 +458,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const pathParts = path.split(".");
const updatedComponents = prevLayout.components.map((comp) => {
if (comp.id !== componentId) {
// 🆕 그룹 설정이면 같은 그룹의 모든 버튼에 적용
const shouldUpdate = isGroupSetting ? affectedComponents.includes(comp.id) : comp.id === componentId;
if (!shouldUpdate) {
// 레이아웃 이동 시 존에 속한 컴포넌트들도 함께 이동
if (isLayoutComponent && (positionDelta.x !== 0 || positionDelta.y !== 0)) {
// 이 레이아웃의 존에 속한 컴포넌트인지 확인
@@ -3467,10 +3497,45 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 그룹 해제
const ungroupedButtons = ungroupButtons(buttons);
// 레이아웃 업데이트
const updatedComponents = layout.components.map((comp) => {
// 레이아웃 업데이트 + 플로우 표시 제어 초기화
const updatedComponents = layout.components.map((comp, index) => {
const ungrouped = ungroupedButtons.find((ub) => ub.id === comp.id);
return ungrouped || comp;
if (ungrouped) {
// 원래 위치 복원 또는 현재 위치 유지 + 간격 추가
const buttonIndex = buttons.findIndex((b) => b.id === comp.id);
const basePosition = comp.position;
// 버튼들을 오른쪽으로 조금씩 이동 (겹치지 않도록)
const offsetX = buttonIndex * 120; // 각 버튼당 120px 간격
// 그룹 해제된 버튼의 플로우 표시 제어를 끄고 설정 초기화
return {
...ungrouped,
position: {
x: basePosition.x + offsetX,
y: basePosition.y,
z: basePosition.z || 1,
},
webTypeConfig: {
...ungrouped.webTypeConfig,
flowVisibilityConfig: {
enabled: false,
targetFlowComponentId: null,
mode: "whitelist",
visibleSteps: [],
hiddenSteps: [],
layoutBehavior: "auto-compact",
groupId: null,
groupDirection: "horizontal",
groupGap: 8,
groupAlign: "start",
},
},
};
}
return comp;
});
const newLayout = {
@@ -3481,7 +3546,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
setLayout(newLayout);
saveToHistory(newLayout);
toast.success(`${buttons.length}개의 버튼 그룹이 해제되었습니다`);
toast.success(`${buttons.length}개의 버튼 그룹이 해제되고 플로우 표시 제어가 비활성화되었습니다`);
}, [layout, groupState.selectedComponents, saveToHistory]);
// 그룹 생성 (임시 비활성화)

View File

@@ -173,6 +173,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
timestamp: new Date().toISOString(),
});
// 현재 버튼에 설정 적용 (그룹 설정은 ScreenDesigner에서 자동으로 일괄 적용됨)
onUpdateProperty("webTypeConfig.flowVisibilityConfig", config);
};
@@ -235,11 +236,13 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
return (
<div className="space-y-4">
<div className="space-y-1">
<h4 className="flex items-center gap-2 text-sm font-medium">
<h4 className="flex items-center gap-2 text-xs font-medium" style={{ fontSize: "12px" }}>
<Workflow className="h-4 w-4" />
</h4>
<p className="text-muted-foreground text-xs"> </p>
<p className="text-muted-foreground text-xs" style={{ fontSize: "12px" }}>
</p>
</div>
<div className="space-y-4">
@@ -253,7 +256,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
setTimeout(() => applyConfig(), 0);
}}
/>
<Label htmlFor="flow-control-enabled" className="text-sm font-medium">
<Label htmlFor="flow-control-enabled" className="text-xs font-medium" style={{ fontSize: "12px" }}>
</Label>
</div>
@@ -262,7 +265,9 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
<>
{/* 대상 플로우 선택 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
</Label>
<Select
value={selectedFlowComponentId || ""}
onValueChange={(value) => {
@@ -270,7 +275,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
setTimeout(() => applyConfig(), 0);
}}
>
<SelectTrigger className="h-6 text-xs sm:h-10 sm:text-xs" style={{ fontSize: "12px" }}>
<SelectTrigger className="h-6 text-xs" style={{ fontSize: "12px" }}>
<SelectValue placeholder="플로우 위젯 선택" />
</SelectTrigger>
<SelectContent>
@@ -278,7 +283,7 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
const flowConfig = (fw as any).componentConfig || {};
const flowName = flowConfig.flowName || `플로우 ${fw.id}`;
return (
<SelectItem key={fw.id} value={fw.id}>
<SelectItem key={fw.id} value={fw.id} style={{ fontSize: "12px" }}>
{flowName}
</SelectItem>
);
@@ -290,261 +295,106 @@ export const FlowVisibilityConfigPanel: React.FC<FlowVisibilityConfigPanelProps>
{/* 플로우가 선택되면 스텝 목록 표시 */}
{selectedFlowComponentId && flowSteps.length > 0 && (
<>
{/* 모드 선택 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<RadioGroup
value={mode}
onValueChange={(value: any) => {
setMode(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="whitelist" id="mode-whitelist" />
<Label htmlFor="mode-whitelist" className="text-sm font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="mode-all" />
<Label htmlFor="mode-all" className="text-sm font-normal">
</Label>
</div>
</RadioGroup>
</div>
{/* 단계 선택 (all 모드가 아닐 때만) */}
{mode !== "all" && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium"> </Label>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectAll} className="h-7 px-2 text-xs">
</Button>
<Button variant="ghost" size="sm" onClick={selectNone} className="h-7 px-2 text-xs">
</Button>
<Button variant="ghost" size="sm" onClick={invertSelection} className="h-7 px-2 text-xs">
</Button>
</div>
</div>
{/* 스텝 체크박스 목록 */}
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
{flowSteps.map((step) => {
const isChecked = visibleSteps.includes(step.id);
return (
<div key={step.id} className="flex items-center gap-2">
<Checkbox
id={`step-${step.id}`}
checked={isChecked}
onCheckedChange={() => toggleStep(step.id)}
/>
<Label
htmlFor={`step-${step.id}`}
className="flex flex-1 items-center gap-2 text-xs"
style={{ fontSize: "12px" }}
>
<Badge variant="outline" className="text-xs">
Step {step.stepOrder}
</Badge>
<span>{step.stepName}</span>
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
</Label>
</div>
);
})}
</div>
</div>
)}
{/* 레이아웃 옵션 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<RadioGroup
value={layoutBehavior}
onValueChange={(value: any) => {
setLayoutBehavior(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="preserve-position" id="layout-preserve" />
<Label htmlFor="layout-preserve" className="text-sm font-normal">
( )
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="auto-compact" id="layout-compact" />
<Label htmlFor="layout-compact" className="text-sm font-normal">
( )
</Label>
</div>
</RadioGroup>
</div>
{/* 🆕 그룹 설정 (auto-compact 모드일 때만 표시) */}
{layoutBehavior === "auto-compact" && (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
</Badge>
<p className="text-muted-foreground text-xs"> ID를 </p>
</div>
{/* 그룹 ID */}
<div className="space-y-2">
<Label htmlFor="group-id" className="text-sm font-medium">
ID
</Label>
<Input
id="group-id"
value={groupId}
onChange={(e) => setGroupId(e.target.value)}
placeholder="group-1"
className="h-6 text-xs sm:h-9 sm:text-xs"
{/* 단계 선택 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium" style={{ fontSize: "12px" }}>
</Label>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={selectAll}
className="h-7 px-2 text-xs"
style={{ fontSize: "12px" }}
/>
<p className="text-muted-foreground text-[10px]">
ID를
</p>
</div>
{/* 정렬 방향 */}
<div className="space-y-2">
<Label className="text-sm font-medium"> </Label>
<RadioGroup
value={groupDirection}
onValueChange={(value: any) => {
setGroupDirection(value);
setTimeout(() => applyConfig(), 0);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="horizontal" id="direction-horizontal" />
<Label htmlFor="direction-horizontal" className="flex items-center gap-2 text-sm font-normal">
<ArrowRight className="h-4 w-4" />
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="vertical" id="direction-vertical" />
<Label htmlFor="direction-vertical" className="flex items-center gap-2 text-sm font-normal">
<ArrowDown className="h-4 w-4" />
</Label>
</div>
</RadioGroup>
</div>
{/* 버튼 간격 */}
<div className="space-y-2">
<Label htmlFor="group-gap" className="text-sm font-medium">
(px)
</Label>
<div className="flex items-center gap-2">
<Input
id="group-gap"
type="number"
min={0}
max={100}
value={groupGap}
onChange={(e) => {
setGroupGap(Number(e.target.value));
setTimeout(() => applyConfig(), 0);
}}
className="h-6 text-xs sm:h-9 sm:text-xs"
style={{ fontSize: "12px" }}
/>
<Badge variant="outline" className="text-xs">
{groupGap}px
</Badge>
</div>
</div>
{/* 정렬 방식 */}
<div className="space-y-2">
<Label htmlFor="group-align" className="text-sm font-medium">
</Label>
<Select
value={groupAlign}
onValueChange={(value: any) => {
setGroupAlign(value);
setTimeout(() => applyConfig(), 0);
}}
</Button>
<Button
variant="ghost"
size="sm"
onClick={selectNone}
className="h-7 px-2 text-xs"
style={{ fontSize: "12px" }}
>
<SelectTrigger
id="group-align"
className="h-6 text-xs sm:h-9 sm:text-xs"
style={{ fontSize: "12px" }}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start"> </SelectItem>
<SelectItem value="center"> </SelectItem>
<SelectItem value="end"> </SelectItem>
<SelectItem value="space-between"> </SelectItem>
<SelectItem value="space-around"> </SelectItem>
</SelectContent>
</Select>
</Button>
<Button
variant="ghost"
size="sm"
onClick={invertSelection}
className="h-7 px-2 text-xs"
style={{ fontSize: "12px" }}
>
</Button>
</div>
</div>
)}
{/* 미리보기 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="text-xs">
{mode === "whitelist" && visibleSteps.length > 0 && (
<div>
<p className="font-medium"> :</p>
<div className="mt-1 flex flex-wrap gap-1">
{visibleSteps.map((stepId) => {
const step = flowSteps.find((s) => s.id === stepId);
return (
<Badge key={stepId} variant="secondary" className="text-xs">
{step?.stepName || `Step ${stepId}`}
</Badge>
);
})}
</div>
</div>
)}
{mode === "blacklist" && hiddenSteps.length > 0 && (
<div>
<p className="font-medium"> :</p>
<div className="mt-1 flex flex-wrap gap-1">
{hiddenSteps.map((stepId) => {
const step = flowSteps.find((s) => s.id === stepId);
return (
<Badge key={stepId} variant="destructive" className="text-xs">
{step?.stepName || `Step ${stepId}`}
</Badge>
);
})}
</div>
</div>
)}
{mode === "all" && <p> .</p>}
{mode === "whitelist" && visibleSteps.length === 0 && <p> .</p>}
</AlertDescription>
</Alert>
{/* 스텝 체크박스 목록 */}
<div className="bg-muted/30 space-y-2 rounded-lg border p-3">
{flowSteps.map((step) => {
const isChecked = visibleSteps.includes(step.id);
{/* 🆕 자동 저장 안내 */}
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-xs text-green-800">
. .
</AlertDescription>
</Alert>
return (
<div key={step.id} className="flex items-center gap-2">
<Checkbox
id={`step-${step.id}`}
checked={isChecked}
onCheckedChange={() => toggleStep(step.id)}
/>
<Label
htmlFor={`step-${step.id}`}
className="flex flex-1 items-center gap-2 text-xs"
style={{ fontSize: "12px" }}
>
<Badge variant="outline" className="text-xs" style={{ fontSize: "12px" }}>
Step {step.stepOrder}
</Badge>
<span>{step.stepName}</span>
{isChecked && <CheckCircle className="ml-auto h-4 w-4 text-green-500" />}
</Label>
</div>
);
})}
</div>
</div>
{/* 정렬 방식 */}
<div className="space-y-2">
<Label htmlFor="group-align" className="text-xs font-medium" style={{ fontSize: "12px" }}>
</Label>
<Select
value={groupAlign}
onValueChange={(value: any) => {
setGroupAlign(value);
onUpdateProperty("webTypeConfig.flowVisibilityConfig.groupAlign", value);
}}
>
<SelectTrigger id="group-align" className="h-6 text-xs" style={{ fontSize: "12px" }}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="start" style={{ fontSize: "12px" }}>
</SelectItem>
<SelectItem value="center" style={{ fontSize: "12px" }}>
</SelectItem>
<SelectItem value="end" style={{ fontSize: "12px" }}>
</SelectItem>
<SelectItem value="space-between" style={{ fontSize: "12px" }}>
</SelectItem>
<SelectItem value="space-around" style={{ fontSize: "12px" }}>
</SelectItem>
</SelectContent>
</Select>
</div>
</>
)}