348 lines
10 KiB
TypeScript
348 lines
10 KiB
TypeScript
"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>
|
|
);
|
|
}
|