React Flow 기본 설정 완료

This commit is contained in:
hyeonsu 2025-09-05 11:30:27 +09:00
parent e4e11fa490
commit 0c765921b7
9 changed files with 774 additions and 6 deletions

View File

@ -613,12 +613,18 @@ PUT /api/external-call-configs/:id
## 📅 구현 계획
### Phase 1: React Flow 기본 설정 (1주)
### Phase 1: React Flow 기본 설정 (1주) ✅ **완료**
- [ ] React Flow 라이브러리 설치 및 설정
- [ ] 기본 노드와 엣지 컴포넌트 구현
- [ ] 화면 노드 컴포넌트 구현
- [ ] 기본 연결선 그리기
- [x] React Flow 라이브러리 설치 및 설정 (@xyflow/react 12.8.4)
- [x] 기본 노드와 엣지 컴포넌트 구현
- [x] 화면 노드 컴포넌트 구현 (ScreenNode.tsx)
- [x] 기본 연결선 그리기 (CustomEdge.tsx)
- [x] 메인 데이터 흐름 관리 컴포넌트 구현 (DataFlowDesigner.tsx)
- [x] /admin/dataflow 페이지 생성
- [x] 메뉴 시스템 연동 (SQL 스크립트 제공)
- [x] 샘플 노드 추가/삭제 기능
- [x] 노드 간 드래그앤드롭 연결 기능
- [x] 줌, 팬, 미니맵 등 React Flow 기본 기능
### Phase 2: 관계 설정 기능 (2주)
@ -659,6 +665,31 @@ PUT /api/external-call-configs/:id
**데이터 흐름 관리 시스템**을 통해 ERP 시스템의 화면들 간 데이터 흐름을 시각적으로 설계하고 관리할 수 있습니다. React Flow 라이브러리를 활용한 직관적인 노드 기반 인터페이스와 회사별 권한 관리, 기존 화면관리 시스템과의 완벽한 연동을 통해 체계적인 데이터 관계 관리가 가능합니다.
## 📊 구현 현황
### ✅ Phase 1 완료 (2024-12-19)
**구현된 기능:**
- React Flow 12.8.4 기반 시각적 캔버스
- 화면 노드 컴포넌트 (필드 정보, 타입별 색상 구분)
- 커스텀 엣지 컴포넌트 (관계 타입별 스타일링)
- 드래그앤드롭 노드 배치 및 연결
- 줌, 팬, 미니맵 등 고급 시각화 기능
- 샘플 데이터 생성 및 관리 기능
- /admin/dataflow 경로 설정
- 메뉴 시스템 연동 준비 완료
**구현된 파일:**
- `frontend/components/dataflow/DataFlowDesigner.tsx`
- `frontend/components/dataflow/ScreenNode.tsx`
- `frontend/components/dataflow/CustomEdge.tsx`
- `frontend/app/(main)/admin/dataflow/page.tsx`
- `docs/add_dataflow_menu.sql` (메뉴 추가 스크립트)
**다음 단계:** Phase 2 - 관계 설정 기능 구현
### 주요 가치
- **React Flow 기반 시각적 설계**: 복잡한 데이터 관계를 직관적인 노드와 엣지로 설계

View File

@ -0,0 +1,23 @@
'use client';
import React from 'react';
import { DataFlowDesigner } from '@/components/dataflow/DataFlowDesigner';
import { useAuth } from '@/hooks/useAuth';
export default function DataFlowPage() {
const { user } = useAuth();
const handleSave = (relationships: any[]) => {
console.log('저장된 관계:', relationships);
// TODO: API 호출로 관계 저장
};
return (
<div className="h-screen bg-gray-50">
<DataFlowDesigner
companyCode={user?.companyCode || 'COMP001'}
onSave={handleSave}
/>
</div>
);
}

View File

@ -0,0 +1,162 @@
"use client";
import React from "react";
import { EdgeProps, getBezierPath, EdgeLabelRenderer, BaseEdge } from "@xyflow/react";
interface CustomEdgeData {
relationshipType: string;
connectionType: string;
label?: string;
}
export const CustomEdge: React.FC<EdgeProps<CustomEdgeData>> = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
data,
markerEnd,
selected,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
// 연결 타입에 따른 색상 반환
const getEdgeColor = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return "#3B82F6"; // 파란색 - 단순 키값 연결
case "data-save":
return "#10B981"; // 초록색 - 데이터 저장
case "external-call":
return "#F59E0B"; // 주황색 - 외부 호출
default:
return "#6B7280"; // 회색 - 기본
}
};
// 연결 타입에 따른 스타일 반환
const getEdgeStyle = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return {
strokeWidth: 2,
strokeDasharray: "5,5",
opacity: selected ? 1 : 0.8,
};
case "data-save":
return {
strokeWidth: 3,
opacity: selected ? 1 : 0.8,
};
case "external-call":
return {
strokeWidth: 2,
strokeDasharray: "10,5",
opacity: selected ? 1 : 0.8,
};
default:
return {
strokeWidth: 2,
opacity: selected ? 1 : 0.6,
};
}
};
// 관계 타입에 따른 아이콘 반환
const getRelationshipIcon = (relationshipType: string) => {
switch (relationshipType) {
case "one-to-one":
return "1:1";
case "one-to-many":
return "1:N";
case "many-to-one":
return "N:1";
case "many-to-many":
return "N:N";
default:
return "1:1";
}
};
// 연결 타입에 따른 설명 반환
const getConnectionTypeDescription = (connectionType: string) => {
switch (connectionType) {
case "simple-key":
return "단순 키값";
case "data-save":
return "데이터 저장";
case "external-call":
return "외부 호출";
default:
return "연결";
}
};
const edgeColor = getEdgeColor(data?.connectionType || "");
const edgeStyle = getEdgeStyle(data?.connectionType || "");
return (
<>
<BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd}
style={{
stroke: edgeColor,
...edgeStyle,
}}
/>
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
background: "white",
padding: "8px 12px",
borderRadius: "8px",
fontSize: "12px",
fontWeight: 600,
border: `2px solid ${edgeColor}`,
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)",
color: edgeColor,
minWidth: "80px",
textAlign: "center",
}}
className={`nodrag nopan transition-all duration-200 ${selected ? "scale-110" : "hover:scale-105"}`}
>
<div className="flex flex-col items-center">
<div className="text-sm font-bold">
{data?.label || getRelationshipIcon(data?.relationshipType || "one-to-one")}
</div>
<div className="mt-1 text-xs opacity-75">
{getConnectionTypeDescription(data?.connectionType || "simple-key")}
</div>
</div>
</div>
</EdgeLabelRenderer>
{/* 선택된 상태일 때 추가 시각적 효과 */}
{selected && (
<BaseEdge
id={`${id}-glow`}
path={edgePath}
style={{
stroke: edgeColor,
strokeWidth: 6,
opacity: 0.3,
}}
/>
)}
</>
);
};

View File

@ -0,0 +1,209 @@
"use client";
import React, { useState, useCallback } from "react";
import {
ReactFlow,
Node,
Edge,
Controls,
Background,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
Connection,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { ScreenNode } from "./ScreenNode";
import { CustomEdge } from "./CustomEdge";
// 노드 및 엣지 타입 정의
const nodeTypes = {
screenNode: ScreenNode,
};
const edgeTypes = {
customEdge: CustomEdge,
};
interface DataFlowDesignerProps {
companyCode: string;
onSave?: (relationships: any[]) => void;
}
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({ companyCode, onSave }) => {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [selectedField, setSelectedField] = useState<{
screenId: string;
fieldName: string;
} | null>(null);
// 노드 연결 처리
const onConnect = useCallback(
(params: Connection) => {
const newEdge = {
...params,
type: "customEdge",
data: {
relationshipType: "one-to-one",
connectionType: "simple-key",
label: "1:1 연결",
},
};
setEdges((eds) => addEdge(newEdge, eds));
},
[setEdges],
);
// 필드 클릭 처리
const handleFieldClick = useCallback((screenId: string, fieldName: string) => {
setSelectedField({ screenId, fieldName });
}, []);
// 샘플 화면 노드 추가
const addSampleNode = useCallback(() => {
const newNode: Node = {
id: `screen-${Date.now()}`,
type: "screenNode",
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
screen: {
screenId: `screen-${Date.now()}`,
screenName: `샘플 화면 ${nodes.length + 1}`,
screenCode: `SCREEN${nodes.length + 1}`,
tableName: `table_${nodes.length + 1}`,
fields: [
{ name: "id", type: "INTEGER", description: "고유 식별자" },
{ name: "name", type: "VARCHAR(100)", description: "이름" },
{ name: "code", type: "VARCHAR(50)", description: "코드" },
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
],
},
onFieldClick: handleFieldClick,
},
};
setNodes((nds) => nds.concat(newNode));
}, [nodes.length, handleFieldClick, setNodes]);
// 노드 전체 삭제
const clearNodes = useCallback(() => {
setNodes([]);
setEdges([]);
}, [setNodes, setEdges]);
return (
<div className="data-flow-designer h-screen bg-gray-100">
<div className="flex h-full">
{/* 사이드바 */}
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
<div className="p-6">
<h2 className="mb-6 text-xl font-bold text-gray-800"> </h2>
{/* 회사 정보 */}
<div className="mb-6 rounded-lg bg-blue-50 p-4">
<div className="text-sm font-medium text-blue-600"> </div>
<div className="text-lg font-bold text-blue-800">{companyCode}</div>
</div>
{/* 컨트롤 버튼들 */}
<div className="space-y-3">
<button
onClick={addSampleNode}
className="w-full rounded-lg bg-blue-500 p-3 font-medium text-white transition-colors hover:bg-blue-600"
>
+
</button>
<button
onClick={clearNodes}
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
>
</button>
<button
onClick={() => onSave && onSave([])}
className="w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600"
>
</button>
</div>
{/* 통계 정보 */}
<div className="mt-6 rounded-lg bg-gray-50 p-4">
<div className="mb-2 text-sm font-semibold text-gray-700"></div>
<div className="space-y-1 text-sm text-gray-600">
<div className="flex justify-between">
<span> :</span>
<span className="font-medium">{nodes.length}</span>
</div>
<div className="flex justify-between">
<span>:</span>
<span className="font-medium">{edges.length}</span>
</div>
</div>
</div>
{/* 선택된 필드 정보 */}
{selectedField && (
<div className="mt-6 rounded-lg border border-yellow-200 bg-yellow-50 p-4">
<div className="mb-2 text-sm font-semibold text-yellow-800"> </div>
<div className="text-sm text-yellow-700">
<div>: {selectedField.screenId}</div>
<div>: {selectedField.fieldName}</div>
</div>
<button
onClick={() => setSelectedField(null)}
className="mt-2 text-xs text-yellow-600 hover:text-yellow-800"
>
</button>
</div>
)}
</div>
</div>
{/* React Flow 캔버스 */}
<div className="relative flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
attributionPosition="bottom-left"
>
<Controls />
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case "screenNode":
return "#3B82F6";
default:
return "#6B7280";
}
}}
/>
<Background variant="dots" gap={20} size={1} color="#E5E7EB" />
</ReactFlow>
{/* 안내 메시지 */}
{nodes.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="text-center text-gray-500">
<div className="mb-2 text-2xl">📊</div>
<div className="mb-1 text-lg font-medium"> </div>
<div className="text-sm"> "샘플 화면 추가" </div>
</div>
</div>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,109 @@
"use client";
import React from "react";
import { Handle, Position } from "@xyflow/react";
interface ScreenField {
name: string;
type: string;
description: string;
}
interface Screen {
screenId: string;
screenName: string;
screenCode: string;
tableName: string;
fields: ScreenField[];
}
interface ScreenNodeData {
screen: Screen;
onFieldClick: (screenId: string, fieldName: string) => void;
}
export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
const { screen, onFieldClick } = data;
// 필드 타입에 따른 색상 반환
const getFieldTypeColor = (type: string) => {
if (type.includes("INTEGER") || type.includes("NUMERIC")) return "text-blue-600 bg-blue-50";
if (type.includes("VARCHAR") || type.includes("TEXT")) return "text-green-600 bg-green-50";
if (type.includes("TIMESTAMP") || type.includes("DATE")) return "text-purple-600 bg-purple-50";
if (type.includes("BOOLEAN")) return "text-orange-600 bg-orange-50";
return "text-gray-600 bg-gray-50";
};
return (
<div className="max-w-96 min-w-80 rounded-lg border-2 border-gray-300 bg-white shadow-lg transition-shadow hover:shadow-xl">
{/* 노드 헤더 */}
<div className="rounded-t-lg bg-gradient-to-r from-blue-500 to-blue-600 p-4 text-white">
<div className="mb-1 text-base font-bold">{screen.screenName}</div>
<div className="mb-1 text-sm opacity-90">ID: {screen.screenCode}</div>
<div className="flex items-center text-xs opacity-75">
<span className="mr-1">🗃</span>
: {screen.tableName}
</div>
</div>
{/* 필드 목록 */}
<div className="p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-gray-700"> </div>
<div className="rounded-full bg-gray-100 px-2 py-1 text-xs text-gray-500">{screen.fields.length}</div>
</div>
<div className="max-h-64 space-y-2 overflow-y-auto">
{screen.fields.map((field, index) => (
<div
key={field.name}
className="flex cursor-pointer items-center justify-between rounded-lg border border-transparent p-3 transition-colors hover:border-gray-200 hover:bg-gray-50"
onClick={() => onFieldClick(screen.screenId, field.name)}
>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center">
<div className="truncate text-sm font-medium text-gray-900">{field.name}</div>
{index === 0 && (
<span className="ml-2 rounded bg-yellow-100 px-1.5 py-0.5 text-xs text-yellow-800">PK</span>
)}
</div>
<div className="truncate text-xs text-gray-500">{field.description}</div>
</div>
<div className={`ml-2 rounded px-2 py-1 font-mono text-xs ${getFieldTypeColor(field.type)}`}>
{field.type}
</div>
</div>
))}
</div>
</div>
{/* React Flow 핸들 */}
<Handle
type="source"
position={Position.Right}
className="h-4 w-4 border-2 border-white bg-blue-500 shadow-md transition-colors hover:bg-blue-600"
title="연결 시작점"
/>
<Handle
type="target"
position={Position.Left}
className="h-4 w-4 border-2 border-white bg-green-500 shadow-md transition-colors hover:bg-green-600"
title="연결 도착점"
/>
{/* 추가 핸들들 (상하) */}
<Handle
type="source"
position={Position.Bottom}
className="h-4 w-4 border-2 border-white bg-blue-500 shadow-md transition-colors hover:bg-blue-600"
id="bottom-source"
/>
<Handle
type="target"
position={Position.Top}
className="h-4 w-4 border-2 border-white bg-green-500 shadow-md transition-colors hover:bg-green-600"
id="top-target"
/>
</div>
);
};

View File

@ -1,4 +1,4 @@
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog } from "lucide-react";
import { ChevronDown, ChevronRight, Home, FileText, Users, BarChart3, Cog, GitBranch } from "lucide-react";
import { cn } from "@/lib/utils";
import { MenuItem } from "@/types/menu";
import { MENU_ICONS, MESSAGES } from "@/constants/layout";
@ -29,6 +29,9 @@ const getMenuIcon = (menuName: string) => {
if (MENU_ICONS.SETTINGS.some((keyword) => menuName.includes(keyword))) {
return <Cog className="h-4 w-4" />;
}
if (MENU_ICONS.DATAFLOW.some((keyword) => menuName.includes(keyword))) {
return <GitBranch className="h-4 w-4" />;
}
return <FileText className="h-4 w-4" />;
};

View File

@ -30,3 +30,12 @@ export const MESSAGES = {
NO_DATA: "데이터가 없습니다.",
NO_MENUS: "사용 가능한 메뉴가 없습니다.",
} as const;
export const MENU_ICONS = {
HOME: ["홈", "메인", "대시보드"],
DOCUMENT: ["문서", "게시판", "공지"],
USERS: ["사용자", "회원", "직원", "인사"],
STATISTICS: ["통계", "분석", "리포트", "차트"],
SETTINGS: ["설정", "관리", "시스템"],
DATAFLOW: ["데이터", "흐름", "관계", "연결"],
} as const;

View File

@ -28,6 +28,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.8.4",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@ -2559,6 +2560,55 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -3167,6 +3217,38 @@
"win32"
]
},
"node_modules/@xyflow/react": {
"version": "12.8.4",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.8.4.tgz",
"integrity": "sha512-bqUu4T5QSHiCFPkoH+b+LROKwQJdLvcjhGbNW9c1dLafCBRjmH1IYz0zPE+lRDXCtQ9kRyFxz3tG19+8VORJ1w==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.68",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.68",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.68.tgz",
"integrity": "sha512-QDG2wxIG4qX+uF8yzm1ULVZrcXX3MxPBoxv7O52FWsX87qIImOqifUhfa/TwsvLdzn7ic2DDBH1uI8TKbdNTYA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@ -3686,6 +3768,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -3804,6 +3892,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@ -8290,6 +8483,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@ -33,6 +33,7 @@
"@radix-ui/react-tabs": "^1.1.12",
"@tanstack/react-query": "^5.85.6",
"@tanstack/react-table": "^8.21.3",
"@xyflow/react": "^12.8.4",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",