플로우 구현

This commit is contained in:
kjs
2025-10-20 10:55:33 +09:00
parent 6603ff81fe
commit f9c171c513
37 changed files with 6881 additions and 238 deletions

View File

@@ -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] || "";

View File

@@ -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>
{/* 선택된 컴포넌트 정보 표시 */}

View File

@@ -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,
}

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}