ERP-node/frontend/components/pop/management/PopScreenFlowView.tsx

348 lines
10 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect, useCallback, useMemo } from "react";
import {
ReactFlow,
Node,
Edge,
Position,
MarkerType,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { cn } from "@/lib/utils";
import { Monitor, Layers, ArrowRight, Loader2 } from "lucide-react";
import { ScreenDefinition } from "@/types/screen";
import { screenApi } from "@/lib/api/screen";
// ============================================================
// 타입 정의
// ============================================================
interface PopScreenFlowViewProps {
screen: ScreenDefinition | null;
className?: string;
onSubScreenSelect?: (subScreenId: string) => void;
}
interface PopLayoutData {
version?: string;
sections?: any[];
mainScreen?: {
id: string;
name: string;
};
subScreens?: SubScreen[];
flow?: FlowConnection[];
}
interface SubScreen {
id: string;
name: string;
type: "modal" | "drawer" | "fullscreen";
triggerFrom?: string; // 어느 화면/버튼에서 트리거되는지
}
interface FlowConnection {
from: string;
to: string;
trigger?: string;
label?: string;
}
// ============================================================
// 커스텀 노드 컴포넌트
// ============================================================
interface ScreenNodeData {
label: string;
type: "main" | "modal" | "drawer" | "fullscreen";
isMain?: boolean;
}
function ScreenNode({ data }: { data: ScreenNodeData }) {
const isMain = data.type === "main" || data.isMain;
return (
<div
className={cn(
"px-4 py-3 rounded-lg border-2 shadow-sm min-w-[140px] text-center transition-colors",
isMain
? "bg-primary/10 border-primary text-primary"
: "bg-background border-muted-foreground/30 hover:border-muted-foreground/50"
)}
>
<div className="flex items-center justify-center gap-2 mb-1">
{isMain ? (
<Monitor className="h-4 w-4" />
) : (
<Layers className="h-4 w-4" />
)}
<span className="text-xs text-muted-foreground">
{isMain ? "메인 화면" : data.type === "modal" ? "모달" : data.type === "drawer" ? "드로어" : "전체화면"}
</span>
</div>
<div className="font-medium text-sm">{data.label}</div>
</div>
);
}
const nodeTypes = {
screenNode: ScreenNode,
};
// ============================================================
// 메인 컴포넌트
// ============================================================
export function PopScreenFlowView({ screen, className, onSubScreenSelect }: PopScreenFlowViewProps) {
const [loading, setLoading] = useState(false);
const [layoutData, setLayoutData] = useState<PopLayoutData | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// 레이아웃 데이터 로드
useEffect(() => {
if (!screen) {
setLayoutData(null);
setNodes([]);
setEdges([]);
return;
}
const loadLayout = async () => {
try {
setLoading(true);
const layout = await screenApi.getLayoutPop(screen.screenId);
if (layout && layout.version === "pop-1.0") {
setLayoutData(layout);
} else {
setLayoutData(null);
}
} catch (error) {
console.error("레이아웃 로드 실패:", error);
setLayoutData(null);
} finally {
setLoading(false);
}
};
loadLayout();
}, [screen]);
// 레이아웃 데이터에서 노드/엣지 생성
useEffect(() => {
if (!layoutData || !screen) {
return;
}
const newNodes: Node[] = [];
const newEdges: Edge[] = [];
// 메인 화면 노드
const mainNodeId = "main";
newNodes.push({
id: mainNodeId,
type: "screenNode",
position: { x: 50, y: 100 },
data: {
label: screen.screenName,
type: "main",
isMain: true,
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
});
// 하위 화면 노드들
const subScreens = layoutData.subScreens || [];
const horizontalGap = 200;
const verticalGap = 100;
subScreens.forEach((subScreen, index) => {
// 세로로 나열, 여러 개일 경우 열 분리
const col = Math.floor(index / 3);
const row = index % 3;
newNodes.push({
id: subScreen.id,
type: "screenNode",
position: {
x: 300 + col * horizontalGap,
y: 50 + row * verticalGap,
},
data: {
label: subScreen.name,
type: subScreen.type || "modal",
},
sourcePosition: Position.Right,
targetPosition: Position.Left,
});
});
// 플로우 연결 (flow 배열 또는 triggerFrom 기반)
const flows = layoutData.flow || [];
if (flows.length > 0) {
// 명시적 flow 배열 사용
flows.forEach((flow, index) => {
newEdges.push({
id: `edge-${index}`,
source: flow.from,
target: flow.to,
type: "smoothstep",
animated: true,
label: flow.label || flow.trigger,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#888",
},
style: { stroke: "#888", strokeWidth: 2 },
});
});
} else {
// triggerFrom 기반으로 엣지 생성 (기본: 메인 → 서브)
subScreens.forEach((subScreen, index) => {
const sourceId = subScreen.triggerFrom || mainNodeId;
newEdges.push({
id: `edge-${index}`,
source: sourceId,
target: subScreen.id,
type: "smoothstep",
animated: true,
markerEnd: {
type: MarkerType.ArrowClosed,
color: "#888",
},
style: { stroke: "#888", strokeWidth: 2 },
});
});
}
setNodes(newNodes);
setEdges(newEdges);
}, [layoutData, screen, setNodes, setEdges]);
// 노드 클릭 핸들러
const onNodeClick = useCallback(
(_: React.MouseEvent, node: Node) => {
if (node.id !== "main" && onSubScreenSelect) {
onSubScreenSelect(node.id);
}
},
[onSubScreenSelect]
);
// 레이아웃 또는 하위 화면이 없는 경우
const hasSubScreens = layoutData?.subScreens && layoutData.subScreens.length > 0;
if (!screen) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm"> .</p>
</div>
</div>
</div>
);
}
if (loading) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
</div>
);
}
if (!layoutData) {
return (
<div className={cn("flex flex-col h-full bg-muted/30", className)}>
<div className="shrink-0 p-3 border-b bg-background">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<ArrowRight className="h-8 w-8 mx-auto mb-3 opacity-50" />
<p className="text-sm">POP .</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("flex flex-col h-full", className)}>
<div className="shrink-0 p-3 border-b bg-background flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium"> </h3>
<span className="text-xs text-muted-foreground">
{screen.screenName}
</span>
</div>
{!hasSubScreens && (
<span className="text-xs text-muted-foreground">
</span>
)}
</div>
<div className="flex-1">
{hasSubScreens ? (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.5}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background color="#ddd" gap={16} />
<Controls showInteractive={false} />
<MiniMap
nodeColor={(node) => (node.data?.isMain ? "#3b82f6" : "#9ca3af")}
maskColor="rgba(0, 0, 0, 0.1)"
className="!bg-muted/50"
/>
</ReactFlow>
) : (
// 하위 화면이 없으면 간단한 단일 노드 표시
<div className="h-full flex items-center justify-center bg-muted/10">
<div className="text-center">
<div className="inline-flex items-center justify-center px-6 py-4 rounded-lg border-2 border-primary bg-primary/10">
<Monitor className="h-5 w-5 mr-2 text-primary" />
<span className="font-medium text-primary">{screen.screenName}</span>
</div>
<p className="text-xs text-muted-foreground mt-4">
() .
<br />
.
</p>
</div>
</div>
)}
</div>
</div>
);
}