모달창 올리기
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
// 그룹 생성 (임시 비활성화)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user