37cac72085
- Docker/K8s 배포 설정을 pipeline-backend/pipeline-front로 통일 - 네임스페이스, 서비스, PVC 등 k8s 리소스명 pipeline-* 로 변경 - AI 에이전트 관리 기능 추가 (에이전트, 그룹, 프로바이더, 대화, API 키, 지식베이스) - 장비 연결 관리 기능 추가 (PLC/Modbus/OPC-UA/MQTT) - 배치 스케줄러에 AI agent/device collection/crawling 타입 추가 - 배치 편집 UI 개선 (6가지 실행 방식 지원) - 회사별 페이지(COMPANY_*) 제거 및 AdminPageRenderer 최적화 - 메뉴 재구성: 장비 연결 관리 시스템관리로 이동, 에이전트 오케스트레이션으로 개명 - ai-assistant 디렉토리 제거 (backend-node로 통합) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
227 lines
6.9 KiB
TypeScript
227 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import {
|
|
Save,
|
|
Undo2,
|
|
Redo2,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
Maximize2,
|
|
Download,
|
|
Trash2,
|
|
Plus,
|
|
} from "lucide-react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
import { useReactFlow } from "reactflow";
|
|
import { SaveConfirmDialog } from "./dialogs/SaveConfirmDialog";
|
|
import { validateFlow, summarizeValidations } from "@/lib/utils/flowValidation";
|
|
import type { FlowValidation } from "@/lib/utils/flowValidation";
|
|
import { useToast } from "@/hooks/use-toast";
|
|
|
|
interface FlowToolbarProps {
|
|
validations?: FlowValidation[];
|
|
onSaveComplete?: (flowId: number, flowName: string) => void;
|
|
onOpenCommandPalette?: () => void;
|
|
}
|
|
|
|
export function FlowToolbar({
|
|
validations = [],
|
|
onSaveComplete,
|
|
onOpenCommandPalette,
|
|
}: FlowToolbarProps) {
|
|
const { toast } = useToast();
|
|
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
|
const {
|
|
flowName,
|
|
setFlowName,
|
|
nodes,
|
|
edges,
|
|
saveFlow,
|
|
exportFlow,
|
|
isSaving,
|
|
selectedNodes,
|
|
removeNodes,
|
|
undo,
|
|
redo,
|
|
canUndo,
|
|
canRedo,
|
|
} = useFlowEditorStore();
|
|
|
|
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
|
|
const handleSaveRef = useRef<() => void>();
|
|
useEffect(() => {
|
|
handleSaveRef.current = handleSave;
|
|
});
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
e.preventDefault();
|
|
if (!isSaving) handleSaveRef.current?.();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [isSaving]);
|
|
|
|
const handleSave = async () => {
|
|
const currentValidations =
|
|
validations.length > 0 ? validations : validateFlow(nodes, edges);
|
|
if (currentValidations.length > 0) {
|
|
setShowSaveDialog(true);
|
|
return;
|
|
}
|
|
await performSave();
|
|
};
|
|
|
|
const performSave = async () => {
|
|
const result = await saveFlow();
|
|
if (result.success) {
|
|
toast({ title: "저장했어요", description: "플로우가 안전하게 저장됐어요", variant: "default" });
|
|
if (onSaveComplete && result.flowId) onSaveComplete(result.flowId, flowName);
|
|
if (window.opener && result.flowId) {
|
|
window.opener.postMessage({ type: "FLOW_SAVED", flowId: result.flowId, flowName }, "*");
|
|
}
|
|
} else {
|
|
toast({ title: "저장에 실패했어요", description: result.message, variant: "destructive" });
|
|
}
|
|
setShowSaveDialog(false);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const json = exportFlow();
|
|
const blob = new Blob([json], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${flowName || "flow"}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
toast({ title: "내보내기 완료", description: "JSON 파일로 저장했어요", variant: "default" });
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (selectedNodes.length === 0) return;
|
|
removeNodes(selectedNodes);
|
|
toast({ title: "노드를 삭제했어요", description: `${selectedNodes.length}개 노드가 삭제됐어요`, variant: "default" });
|
|
};
|
|
|
|
const ToolBtn = ({
|
|
onClick,
|
|
disabled,
|
|
title,
|
|
danger,
|
|
children,
|
|
}: {
|
|
onClick: () => void;
|
|
disabled?: boolean;
|
|
title: string;
|
|
danger?: boolean;
|
|
children: React.ReactNode;
|
|
}) => (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
title={title}
|
|
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-colors disabled:opacity-30 ${
|
|
danger
|
|
? "text-destructive hover:bg-destructive/10"
|
|
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div className="flex items-center gap-1 rounded-xl border bg-background/95 px-2 py-1.5 shadow-sm backdrop-blur-sm">
|
|
{/* 노드 추가 */}
|
|
{onOpenCommandPalette && (
|
|
<>
|
|
<button
|
|
onClick={onOpenCommandPalette}
|
|
title="노드 추가 (/)"
|
|
className="flex h-8 items-center gap-1.5 rounded-lg bg-primary/10 px-2.5 text-primary transition-colors hover:bg-primary/20"
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
<span className="text-xs font-medium">추가</span>
|
|
</button>
|
|
<div className="mx-0.5 h-5 w-px bg-border" />
|
|
</>
|
|
)}
|
|
|
|
{/* 플로우 이름 */}
|
|
<Input
|
|
value={flowName}
|
|
onChange={(e) => setFlowName(e.target.value)}
|
|
onKeyDown={(e) => e.stopPropagation()}
|
|
className="h-7 w-[160px] border-none bg-transparent px-2 text-xs font-medium placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
placeholder="플로우 이름을 입력해요"
|
|
/>
|
|
|
|
<div className="mx-0.5 h-5 w-px bg-border" />
|
|
|
|
{/* Undo / Redo */}
|
|
<ToolBtn onClick={undo} disabled={!canUndo()} title="실행 취소 (Ctrl+Z)">
|
|
<Undo2 className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
<ToolBtn onClick={redo} disabled={!canRedo()} title="다시 실행 (Ctrl+Y)">
|
|
<Redo2 className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
|
|
{/* 삭제 */}
|
|
{selectedNodes.length > 0 && (
|
|
<>
|
|
<div className="mx-0.5 h-5 w-px bg-border" />
|
|
<ToolBtn onClick={handleDelete} title={`${selectedNodes.length}개 삭제`} danger>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
</>
|
|
)}
|
|
|
|
<div className="mx-0.5 h-5 w-px bg-border" />
|
|
|
|
{/* 줌 */}
|
|
<ToolBtn onClick={() => zoomIn()} title="확대">
|
|
<ZoomIn className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
<ToolBtn onClick={() => zoomOut()} title="축소">
|
|
<ZoomOut className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
<ToolBtn onClick={() => fitView()} title="전체 보기">
|
|
<Maximize2 className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
|
|
<div className="mx-0.5 h-5 w-px bg-border" />
|
|
|
|
{/* 저장 */}
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
title="저장 (Ctrl+S)"
|
|
className="flex h-8 items-center gap-1.5 rounded-lg px-2.5 transition-colors hover:bg-accent disabled:opacity-40"
|
|
>
|
|
<Save className="h-3.5 w-3.5" />
|
|
<span className="text-xs font-medium">{isSaving ? "저장 중..." : "저장"}</span>
|
|
</button>
|
|
|
|
{/* JSON 내보내기 */}
|
|
<ToolBtn onClick={handleExport} title="JSON 내보내기">
|
|
<Download className="h-3.5 w-3.5" />
|
|
</ToolBtn>
|
|
</div>
|
|
|
|
<SaveConfirmDialog
|
|
open={showSaveDialog}
|
|
validations={validations.length > 0 ? validations : validateFlow(nodes, edges)}
|
|
onConfirm={performSave}
|
|
onCancel={() => setShowSaveDialog(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|