플로우 구현
This commit is contained in:
@@ -307,6 +307,39 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
// 플로우 위젯 컴포넌트 처리
|
||||
if (comp.type === "flow" || (comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget")) {
|
||||
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
|
||||
// componentConfig에서 flowId 추출
|
||||
const flowConfig = (comp as any).componentConfig || {};
|
||||
|
||||
console.log("🔍 InteractiveScreenViewer 플로우 위젯 변환:", {
|
||||
compType: comp.type,
|
||||
hasComponentConfig: !!(comp as any).componentConfig,
|
||||
flowConfig,
|
||||
flowConfigFlowId: flowConfig.flowId,
|
||||
finalFlowId: flowConfig.flowId,
|
||||
});
|
||||
|
||||
const flowComponent = {
|
||||
...comp,
|
||||
type: "flow" as const,
|
||||
flowId: flowConfig.flowId,
|
||||
flowName: flowConfig.flowName,
|
||||
showStepCount: flowConfig.showStepCount !== false,
|
||||
allowDataMove: flowConfig.allowDataMove || false,
|
||||
displayMode: flowConfig.displayMode || "horizontal",
|
||||
};
|
||||
|
||||
console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<FlowWidget component={flowComponent as any} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||
const fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
||||
@@ -66,7 +66,7 @@ interface RealtimePreviewProps {
|
||||
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
||||
switch (layoutDirection) {
|
||||
case "horizontal":
|
||||
return <Layout className="h-4 w-4 text-primary" />;
|
||||
return <Layout className="text-primary h-4 w-4" />;
|
||||
case "vertical":
|
||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||
default:
|
||||
@@ -86,7 +86,7 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
||||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(layoutDirection)}
|
||||
<p className="mt-2 text-sm text-muted-foreground">{label || `${layoutDirection || "기본"} 영역`}</p>
|
||||
<p className="text-muted-foreground mt-2 text-sm">{label || `${layoutDirection || "기본"} 영역`}</p>
|
||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,12 +130,12 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
||||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||
if (isFileComponent(widget)) {
|
||||
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||
// componentId: widget.id,
|
||||
// widgetType: widgetType,
|
||||
// isFileComponent: true
|
||||
// componentId: widget.id,
|
||||
// widgetType: widgetType,
|
||||
// isFileComponent: true
|
||||
// });
|
||||
|
||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
||||
|
||||
return <div className="p-2 text-xs text-gray-500">파일 컴포넌트 (별도 렌더링)</div>;
|
||||
}
|
||||
|
||||
// 동적 웹타입 렌더링 사용
|
||||
@@ -182,7 +182,7 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||
case "text":
|
||||
case "email":
|
||||
case "tel":
|
||||
return <Type className="h-4 w-4 text-primary" />;
|
||||
return <Type className="text-primary h-4 w-4" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-4 w-4 text-green-600" />;
|
||||
@@ -196,11 +196,11 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-4 w-4 text-primary" />;
|
||||
return <CheckSquare className="text-primary h-4 w-4" />;
|
||||
case "radio":
|
||||
return <Radio className="h-4 w-4 text-primary" />;
|
||||
return <Radio className="text-primary h-4 w-4" />;
|
||||
case "code":
|
||||
return <Code className="h-4 w-4 text-muted-foreground" />;
|
||||
return <Code className="text-muted-foreground h-4 w-4" />;
|
||||
case "entity":
|
||||
return <Building className="h-4 w-4 text-cyan-600" />;
|
||||
case "file":
|
||||
@@ -227,39 +227,39 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: event.detail.componentId === component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1,
|
||||
// eventDetail: event.detail
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: event.detail.componentId === component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1,
|
||||
// eventDetail: event.detail
|
||||
// });
|
||||
|
||||
|
||||
if (event.detail.componentId === component.id) {
|
||||
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||
// componentId: component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// oldTrigger: fileUpdateTrigger,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// componentId: component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// oldTrigger: fileUpdateTrigger,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// });
|
||||
setFileUpdateTrigger(prev => {
|
||||
setFileUpdateTrigger((prev) => {
|
||||
const newTrigger = prev + 1;
|
||||
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// });
|
||||
return newTrigger;
|
||||
});
|
||||
} else {
|
||||
// console.log("❌ 컴포넌트 ID 불일치:", {
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id
|
||||
// });
|
||||
}
|
||||
};
|
||||
@@ -267,34 +267,34 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
// 강제 업데이트 함수 등록
|
||||
const forceUpdate = (componentId: string, files: any[]) => {
|
||||
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||
// targetComponentId: componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: componentId === component.id,
|
||||
// filesCount: files.length
|
||||
// targetComponentId: componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: componentId === component.id,
|
||||
// filesCount: files.length
|
||||
// });
|
||||
|
||||
|
||||
if (componentId === component.id) {
|
||||
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||
// componentId: component.id,
|
||||
// filesCount: files.length,
|
||||
// oldTrigger: fileUpdateTrigger
|
||||
// componentId: component.id,
|
||||
// filesCount: files.length,
|
||||
// oldTrigger: fileUpdateTrigger
|
||||
// });
|
||||
setFileUpdateTrigger(prev => {
|
||||
setFileUpdateTrigger((prev) => {
|
||||
const newTrigger = prev + 1;
|
||||
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id
|
||||
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id
|
||||
// });
|
||||
return newTrigger;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
|
||||
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
|
||||
// 전역 강제 업데이트 함수 등록
|
||||
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||
(window as any).forceRealtimePreviewUpdate = forceUpdate;
|
||||
@@ -302,10 +302,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
} catch (error) {
|
||||
// console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
|
||||
}
|
||||
|
||||
|
||||
return () => {
|
||||
try {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
} catch (error) {
|
||||
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
}
|
||||
: {};
|
||||
@@ -395,6 +395,39 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 플로우 위젯 타입 */}
|
||||
{(type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget")) &&
|
||||
(() => {
|
||||
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
|
||||
// componentConfig에서 flowId 추출
|
||||
const flowConfig = (component as any).componentConfig || {};
|
||||
|
||||
console.log("🔍 RealtimePreview 플로우 위젯 변환:", {
|
||||
compType: component.type,
|
||||
hasComponentConfig: !!(component as any).componentConfig,
|
||||
flowConfig,
|
||||
flowConfigFlowId: flowConfig.flowId,
|
||||
});
|
||||
|
||||
const flowComponent = {
|
||||
...component,
|
||||
type: "flow" as const,
|
||||
flowId: flowConfig.flowId,
|
||||
flowName: flowConfig.flowName,
|
||||
showStepCount: flowConfig.showStepCount !== false,
|
||||
allowDataMove: flowConfig.allowDataMove || false,
|
||||
displayMode: flowConfig.displayMode || "horizontal",
|
||||
};
|
||||
|
||||
console.log("🔍 RealtimePreview 최종 flowComponent:", flowComponent);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<FlowWidget component={flowComponent as any} />
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 그룹 타입 */}
|
||||
{type === "group" && (
|
||||
<div className="relative h-full w-full">
|
||||
@@ -412,18 +445,19 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
)}
|
||||
|
||||
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||
{isFileComponent(component) && (() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
|
||||
// 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
|
||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
||||
|
||||
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
||||
{isFileComponent(component) &&
|
||||
(() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
|
||||
// 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
|
||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
||||
|
||||
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
||||
// componentId: component.id,
|
||||
// uploadedFilesCount: uploadedFiles.length,
|
||||
// globalFilesCount: globalFiles.length,
|
||||
@@ -432,73 +466,76 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
// componentType: component.type,
|
||||
// fileUpdateTrigger: fileUpdateTrigger,
|
||||
// timestamp: new Date().toISOString()
|
||||
// });
|
||||
|
||||
return (
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||
업로드된 파일 ({currentFiles.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentFiles.map((file: any, index: number) => {
|
||||
// 파일 확장자에 따른 아이콘 선택
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 text-red-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
|
||||
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
|
||||
}
|
||||
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
|
||||
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
|
||||
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
|
||||
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
|
||||
}
|
||||
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
|
||||
{getFileIcon(file.realFileName || file.name || '')}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium text-gray-900">
|
||||
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
|
||||
</p>
|
||||
// });
|
||||
|
||||
return (
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||
업로드된 파일 ({currentFiles.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentFiles.map((file: any, index: number) => {
|
||||
// 파일 확장자에 따른 아이콘 선택
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
||||
return <ImageIcon className="h-4 w-4 flex-shrink-0 text-green-500" />;
|
||||
}
|
||||
if (["pdf", "doc", "docx", "txt", "rtf", "hwp", "hwpx", "hwpml", "pages"].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
||||
}
|
||||
if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) {
|
||||
return <Presentation className="h-4 w-4 flex-shrink-0 text-orange-600" />;
|
||||
}
|
||||
if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 flex-shrink-0 text-green-600" />;
|
||||
}
|
||||
if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) {
|
||||
return <Video className="h-4 w-4 flex-shrink-0 text-purple-500" />;
|
||||
}
|
||||
if (["mp3", "wav", "flac", "aac"].includes(ext)) {
|
||||
return <Music className="h-4 w-4 flex-shrink-0 text-orange-500" />;
|
||||
}
|
||||
if (["zip", "rar", "7z", "tar"].includes(ext)) {
|
||||
return <Archive className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
|
||||
}
|
||||
return <File className="h-4 w-4 flex-shrink-0 text-blue-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file.objid || index}
|
||||
className="flex items-center space-x-2 rounded bg-white p-2 text-xs"
|
||||
>
|
||||
{getFileIcon(file.realFileName || file.name || "")}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-900">
|
||||
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">업로드된 파일 (0)</p>
|
||||
<p className="text-sm text-muted-foreground">파일 업로드 영역</p>
|
||||
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="mb-1 text-xs font-medium text-gray-700">업로드된 파일 (0)</p>
|
||||
<p className="text-muted-foreground text-sm">파일 업로드 영역</p>
|
||||
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid hsl(var(--primary))",
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
zIndex: 20,
|
||||
}
|
||||
|
||||
@@ -1997,6 +1997,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
"table-list": 12, // 테이블 리스트 (100%)
|
||||
"image-display": 4, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
||||
"flow-widget": 12, // 플로우 위젯 (100%)
|
||||
|
||||
// 액션 컴포넌트 (ACTION 카테고리)
|
||||
"button-basic": 1, // 버튼 (8.33%)
|
||||
@@ -2016,8 +2017,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||
"chart-basic": 6, // 차트 (50%)
|
||||
};
|
||||
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
||||
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||
gridColumns = 12;
|
||||
} else {
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
}
|
||||
|
||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||
componentId,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { getFlowDefinitions } from "@/lib/api/flow";
|
||||
import type { FlowDefinition } from "@/types/flow";
|
||||
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface FlowWidgetConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
onChange: (config: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfigPanelProps) {
|
||||
const [flowList, setFlowList] = useState<FlowDefinition[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openCombobox, setOpenCombobox] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadFlows = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getFlowDefinitions({ isActive: true });
|
||||
if (response.success && response.data) {
|
||||
setFlowList(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load flows:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFlows();
|
||||
}, []);
|
||||
|
||||
const selectedFlow = flowList.find((flow) => flow.id === config.flowId);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium">플로우 선택</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>플로우</Label>
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedFlow ? selectedFlow.name : "플로우 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="플로우 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>플로우를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{flowList.map((flow) => (
|
||||
<CommandItem
|
||||
key={flow.id}
|
||||
value={flow.name}
|
||||
onSelect={() => {
|
||||
onChange({
|
||||
...config,
|
||||
flowId: flow.id,
|
||||
flowName: flow.name,
|
||||
});
|
||||
setOpenCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", config.flowId === flow.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<span className="font-medium">{flow.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedFlow && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">테이블: {selectedFlow.tableName || "없음"}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium">표시 옵션</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>표시 방향</Label>
|
||||
<Select
|
||||
value={config.displayMode || "horizontal"}
|
||||
onValueChange={(value: "horizontal" | "vertical") => onChange({ ...config, displayMode: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horizontal">가로 (→)</SelectItem>
|
||||
<SelectItem value="vertical">세로 (↓)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>데이터 건수 표시</Label>
|
||||
<p className="text-muted-foreground text-xs">각 스텝의 데이터 건수를 표시합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.showStepCount !== false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, showStepCount: checked })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label>데이터 이동 허용</Label>
|
||||
<p className="text-muted-foreground text-xs">사용자가 데이터를 다음 스텝으로 이동할 수 있습니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config.allowDataMove || false}
|
||||
onCheckedChange={(checked) => onChange({ ...config, allowDataMove: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1056,33 +1056,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 타입 선택 영역 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-6 pt-0">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDetailTypes.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
@@ -1115,23 +1088,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 웹타입별 특화 설정 */}
|
||||
{webType && (
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="mb-4 text-sm font-semibold text-gray-900">웹타입 설정</h4>
|
||||
<WebTypeConfigPanel
|
||||
webType={webType as any}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
// 기존 설정과 병합하여 업데이트
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -548,22 +548,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 웹타입별 특화 설정 */}
|
||||
{webType && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="mb-2 text-sm font-semibold">웹타입 설정</h4>
|
||||
<WebTypeConfigPanel
|
||||
webType={webType as any}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -592,17 +576,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* WebType 설정 패널 */}
|
||||
<WebTypeConfigPanel
|
||||
webType={widget.webType as any}
|
||||
config={widget.webTypeConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`webTypeConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
238
frontend/components/screen/widgets/FlowWidget.tsx
Normal file
238
frontend/components/screen/widgets/FlowWidget.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FlowComponent } from "@/types/screen-management";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts } from "@/lib/api/flow";
|
||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
||||
import { FlowDataListModal } from "@/components/flow/FlowDataListModal";
|
||||
|
||||
interface FlowWidgetProps {
|
||||
component: FlowComponent;
|
||||
onStepClick?: (stepId: number, stepName: string) => void;
|
||||
}
|
||||
|
||||
export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
||||
const [flowData, setFlowData] = useState<FlowDefinition | null>(null);
|
||||
const [steps, setSteps] = useState<FlowStep[]>([]);
|
||||
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStep, setSelectedStep] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
|
||||
const config = (component as any).componentConfig || (component as any).config || {};
|
||||
const flowId = config.flowId || component.flowId;
|
||||
const flowName = config.flowName || component.flowName;
|
||||
const displayMode = config.displayMode || component.displayMode || "horizontal";
|
||||
const showStepCount = config.showStepCount !== false && component.showStepCount !== false; // 기본값 true
|
||||
const allowDataMove = config.allowDataMove || component.allowDataMove || false;
|
||||
|
||||
console.log("🔍 FlowWidget 렌더링:", {
|
||||
component,
|
||||
componentConfig: config,
|
||||
flowId,
|
||||
flowName,
|
||||
displayMode,
|
||||
showStepCount,
|
||||
allowDataMove,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🔍 FlowWidget useEffect 실행:", {
|
||||
flowId,
|
||||
hasFlowId: !!flowId,
|
||||
config,
|
||||
});
|
||||
|
||||
if (!flowId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadFlowData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 플로우 정보 조회
|
||||
const flowResponse = await getFlowById(flowId!);
|
||||
if (!flowResponse.success || !flowResponse.data) {
|
||||
throw new Error("플로우를 찾을 수 없습니다");
|
||||
}
|
||||
|
||||
setFlowData(flowResponse.data);
|
||||
|
||||
// 스텝 목록 조회
|
||||
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
|
||||
if (!stepsResponse.ok) {
|
||||
throw new Error("스텝 목록을 불러올 수 없습니다");
|
||||
}
|
||||
const stepsData = await stepsResponse.json();
|
||||
if (stepsData.success && stepsData.data) {
|
||||
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
||||
setSteps(sortedSteps);
|
||||
|
||||
// 스텝별 데이터 건수 조회
|
||||
if (showStepCount) {
|
||||
const countsResponse = await getAllStepCounts(flowId!);
|
||||
if (countsResponse.success && countsResponse.data) {
|
||||
// 배열을 Record<number, number>로 변환
|
||||
const countsMap: Record<number, number> = {};
|
||||
countsResponse.data.forEach((item: any) => {
|
||||
countsMap[item.stepId] = item.count;
|
||||
});
|
||||
setStepCounts(countsMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to load flow data:", err);
|
||||
setError(err.message || "플로우 데이터를 불러오는데 실패했습니다");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFlowData();
|
||||
}, [flowId, showStepCount]);
|
||||
|
||||
// 스텝 클릭 핸들러
|
||||
const handleStepClick = (stepId: number, stepName: string) => {
|
||||
if (onStepClick) {
|
||||
onStepClick(stepId, stepName);
|
||||
} else {
|
||||
// 기본 동작: 모달 열기
|
||||
setSelectedStep({ id: stepId, name: stepName });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 이동 후 리프레시
|
||||
const handleDataMoved = async () => {
|
||||
if (!flowId) return;
|
||||
|
||||
try {
|
||||
// 스텝별 데이터 건수 다시 조회
|
||||
const countsResponse = await getAllStepCounts(flowId);
|
||||
if (countsResponse.success && countsResponse.data) {
|
||||
// 배열을 Record<number, number>로 변환
|
||||
const countsMap: Record<number, number> = {};
|
||||
countsResponse.data.forEach((item: any) => {
|
||||
countsMap[item.stepId] = item.count;
|
||||
});
|
||||
setStepCounts(countsMap);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh step counts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-sm">플로우 로딩 중...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="border-destructive/50 bg-destructive/10 flex items-center gap-2 rounded-lg border p-4">
|
||||
<AlertCircle className="text-destructive h-5 w-5" />
|
||||
<span className="text-destructive text-sm">{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!flowId || !flowData) {
|
||||
return (
|
||||
<div className="border-muted-foreground/25 flex items-center justify-center rounded-lg border-2 border-dashed p-8">
|
||||
<span className="text-muted-foreground text-sm">플로우를 선택해주세요</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="border-muted flex items-center justify-center rounded-lg border p-8">
|
||||
<span className="text-muted-foreground text-sm">플로우에 스텝이 없습니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const containerClass =
|
||||
displayMode === "horizontal"
|
||||
? "flex flex-wrap items-center justify-center gap-3"
|
||||
: "flex flex-col items-center gap-4";
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full p-4">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="text-foreground text-lg font-semibold">{flowData.name}</h3>
|
||||
{flowData.description && <p className="text-muted-foreground mt-1 text-sm">{flowData.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
<div className={containerClass}>
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:border-primary hover:bg-accent flex shrink-0 flex-col items-start gap-3 p-5"
|
||||
onClick={() => handleStepClick(step.id, step.stepName)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
단계 {step.stepOrder}
|
||||
</Badge>
|
||||
{showStepCount && (
|
||||
<Badge variant="secondary" className="text-sm font-semibold">
|
||||
{stepCounts[step.id] || 0}건
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-left">
|
||||
<div className="text-foreground text-base font-semibold">{step.stepName}</div>
|
||||
{step.tableName && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
|
||||
<span>📊</span>
|
||||
<span>{step.tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* 화살표 (마지막 스텝 제외) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="text-muted-foreground flex shrink-0 items-center justify-center text-2xl font-bold">
|
||||
{displayMode === "horizontal" ? "→" : "↓"}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 목록 모달 */}
|
||||
{selectedStep && flowId && (
|
||||
<FlowDataListModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
flowId={flowId}
|
||||
stepId={selectedStep.id}
|
||||
stepName={selectedStep.name}
|
||||
allowDataMove={allowDataMove}
|
||||
onDataMoved={handleDataMoved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user