diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9aac8719..414aaecb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "3.47.2-beta.0", + "version": "3.69.0-beta.0", "dependencies": { "@fullcalendar/core": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20", @@ -25,6 +25,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", @@ -3460,6 +3461,55 @@ "assertion-error": "^2.0.1" } }, + "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/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -3996,6 +4046,48 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xyflow/react": { + "version": "12.11.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.1.tgz", + "integrity": "sha512-L+zBoLGSXham0MnlY8QqjfR7/C5JNw0zxkaey5aZ5XmCgJBAdH4+WRIu8CR40d3l/BdU635V6YbhBK1jMo8/6Q==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.78", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.78", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.78.tgz", + "integrity": "sha512-lY0z2qP33fUhTva9Vaxrk0lqZta2pkbxB1trHAx1omnJqRtPvDlAQYV2r5fhS6AdpkulYmbNW0svy+A4/t4B/g==", + "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", @@ -4435,6 +4527,12 @@ "node": ">= 6" } }, + "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/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -4634,6 +4732,111 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "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/data-urls": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", @@ -9297,6 +9500,34 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "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 + } + } } } } diff --git a/frontend/package.json b/frontend/package.json index 2a6b6033..da03cbb7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,6 +32,7 @@ "@types/dompurify": "^3.0.5", "@types/lodash": "^4.17.20", "@types/react-google-recaptcha": "^2.1.9", + "@xyflow/react": "^12.11.1", "axios": "1.15.2", "clsx": "^2.0.0", "date-fns": "4.1.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 169a0297..959628d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage'; import { ContractResponsePage } from './pages/public/ContractResponsePage'; import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage'; import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage'; +import { WorkflowsListPage } from './pages/admin/workflows/WorkflowsListPage'; +import { WorkflowApprovalsPage } from './pages/admin/workflows/WorkflowApprovalsPage'; +import { WorkflowEditorPage } from './pages/admin/workflows/WorkflowEditorPage'; import { ContractsListPage } from './pages/admin/contracts/ContractsListPage'; import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage'; import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage'; @@ -314,6 +317,14 @@ function App() { } /> } /> + {/* Workflows (automation engine) — top-level area gated + by the `workflows` flag. */} + }> + } /> + } /> + } /> + + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 2dbd26d0..6a18cd09 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -11,6 +11,7 @@ import { Users, Briefcase, Landmark, + Workflow, PanelLeftClose, PanelLeftOpen, } from 'lucide-react'; @@ -110,6 +111,12 @@ const navigation: NavItem[] = [ permission: 'accounting.view', featureFlag: 'accounting', }, + // Workflows (automation engine) — top-level, gated by the `workflows` flag. + { + nameKey: 'navigation.workflows', href: '/admin/workflows', icon: Workflow, + permission: 'workflows.view', + featureFlag: 'workflows', + }, ]; export const AdminSidebar: React.FC = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => { diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 9b680e94..474cda48 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -199,6 +199,7 @@ "calendar": "Kalender", "clients": "CRM", "accounting": "Buchhaltung", + "workflows": "Workflows", "betaTag": "Beta" }, "eventTypes": { diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index cf3c178b..1b86df49 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -199,6 +199,7 @@ "calendar": "Calendar", "clients": "CRM", "accounting": "Accounting", + "workflows": "Workflows", "betaTag": "Beta" }, "archives": { diff --git a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx new file mode 100644 index 00000000..6f4f7eed --- /dev/null +++ b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx @@ -0,0 +1,81 @@ +/** + * Admin → Workflows → Approvals inbox. Lists workflow runs paused on a gate + * (e.g. "confirm there's no payment") and lets the admin confirm or deny, + * resuming the run down the matching edge. The same decision is also possible + * from the emailed confirm/deny link. + */ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { ArrowLeft, Check, X } from 'lucide-react'; +import { Button, Card, Loading } from '../../../components/common'; +import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service'; +import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; + +export const WorkflowApprovalsPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const { formatDateTime } = useLocalizedDate(); + + const { data: approvals, isLoading } = useQuery({ + queryKey: ['workflow-approvals'], + queryFn: () => workflowsService.approvals(), + }); + + const actMutation = useMutation({ + mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); + toast.success(t('workflows.approvals.recorded', 'Response recorded') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + }); + + const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.'); + + return ( +
+
+ +
+

{t('workflows.approvals.title', 'Approvals')}

+

{t('workflows.approvals.subtitle', 'Workflow runs waiting on your confirmation.')}

+
+
+ + + {isLoading ? ( +
+ ) : !approvals || approvals.length === 0 ? ( +
{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}
+ ) : ( +
    + {approvals.map((a) => ( +
  • +
    +
    {promptOf(a)}
    +
    + {a.workflow_name} + {a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''} + {a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''} +
    +
    + + +
  • + ))} +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx new file mode 100644 index 00000000..52feaba5 --- /dev/null +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -0,0 +1,229 @@ +/** + * Admin → Workflows → canvas editor (React Flow). + * + * Drag nodes from the palette, connect handle→handle (branch/gate/loop expose + * yes/no · confirm/deny · loop/exit handles), click a node to edit its config + * in the side panel, and Save (writes a new version; in-flight runs keep + * theirs). The graph maps 1:1 onto workflow_nodes/workflow_edges. + */ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { + ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, + Handle, Position, type Connection, type Node, type Edge, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { ArrowLeft, Save, Trash2 } from 'lucide-react'; +import { Button, Loading } from '../../../components/common'; +import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service'; + +const PALETTE: WorkflowNodeType[] = ['trigger', 'condition', 'branch', 'loop', 'wait', 'action', 'gate', 'webhook']; +const TRIGGERS = [ + 'invoice.sent', 'invoice.paid', 'invoice.overdue', 'quote.accepted', 'quote.declined', + 'contract.signed', 'event.date_approaching', 'gallery.published', 'gallery.expiring', 'customer.created', +]; + +const COLORS: Record = { + trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD', + wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780', +}; + +const SOURCE_HANDLES: Record = { + condition: ['yes', 'no'], branch: ['yes', 'no'], gate: ['confirm', 'deny'], loop: ['loop', 'exit'], +}; + +function WfNode({ data }: { data: { label: string; nodeType: string } }) { + const handles = SOURCE_HANDLES[data.nodeType]; + const color = COLORS[data.nodeType] || '#888780'; + return ( +
+ {data.nodeType !== 'trigger' && } +
{data.nodeType}
+
{data.label}
+ {handles ? ( + handles.map((h, i) => ( + + + )) + ) : ( + + )} +
+ ); +} + +const nodeTypes = { wf: WfNode }; + +export const WorkflowEditorPage: React.FC = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const { id } = useParams<{ id: string }>(); + const workflowId = Number(id); + + const { data: workflow, isLoading } = useQuery({ + queryKey: ['workflow', workflowId], + queryFn: () => workflowsService.get(workflowId), + enabled: Number.isFinite(workflowId), + }); + + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [name, setName] = useState(''); + const [triggerType, setTriggerType] = useState('invoice.sent'); + const [enabled, setEnabled] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [configText, setConfigText] = useState('{}'); + const [counter, setCounter] = useState(1); + + useEffect(() => { + if (!workflow) return; + setName(workflow.name); + setTriggerType(workflow.trigger_type); + setEnabled(workflow.enabled === true || workflow.enabled === 1); + setNodes(workflow.nodes.map((n) => ({ + id: n.node_key, + type: 'wf', + position: { x: n.pos_x || 0, y: n.pos_y || 0 }, + data: { label: n.node_key, nodeType: n.type, config: n.config || {} }, + }))); + setEdges(workflow.edges.map((e, i) => ({ + id: `e${i}`, + source: e.from_node, + target: e.to_node, + sourceHandle: e.from_handle || undefined, + label: e.from_handle || undefined, + }))); + }, [workflow, setNodes, setEdges]); + + const onConnect = useCallback((c: Connection) => { + setEdges((eds) => addEdge({ ...c, label: c.sourceHandle || undefined }, eds)); + }, [setEdges]); + + const addNode = (type: WorkflowNodeType) => { + const key = type === 'trigger' ? 'trigger' : `${type}_${counter}`; + setCounter((c) => c + 1); + setNodes((nds) => nds.concat({ + id: key, type: 'wf', position: { x: 120 + Math.random() * 240, y: 120 + Math.random() * 240 }, + data: { label: key, nodeType: type, config: {} }, + })); + }; + + const selectedNode = useMemo(() => nodes.find((n) => n.id === selectedId) || null, [nodes, selectedId]); + useEffect(() => { + if (selectedNode) setConfigText(JSON.stringify((selectedNode.data as any).config || {}, null, 2)); + }, [selectedNode]); + + const applyConfig = () => { + if (!selectedId) return; + let parsed: Record; + try { parsed = JSON.parse(configText || '{}'); } catch (e) { toast.error(t('workflows.editor.badJson', 'Config is not valid JSON') as string); return; } + setNodes((nds) => nds.map((n) => (n.id === selectedId ? { ...n, data: { ...n.data, config: parsed } } : n))); + toast.success(t('workflows.editor.configApplied', 'Config applied (remember to Save)') as string); + }; + + const deleteSelected = () => { + if (!selectedId) return; + setNodes((nds) => nds.filter((n) => n.id !== selectedId)); + setEdges((eds) => eds.filter((e) => e.source !== selectedId && e.target !== selectedId)); + setSelectedId(null); + }; + + const saveMutation = useMutation({ + mutationFn: () => workflowsService.update(workflowId, { + name: name.trim() || 'Untitled', + trigger_type: triggerType, + enabled, + nodes: nodes.map((n) => ({ + node_key: n.id, type: (n.data as any).nodeType, config: (n.data as any).config || {}, + pos_x: Math.round(n.position.x), pos_y: Math.round(n.position.y), + })), + edges: edges.map((e) => ({ + from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target, + })), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['workflow', workflowId] }); + qc.invalidateQueries({ queryKey: ['workflows'] }); + toast.success(t('workflows.editor.saved', 'Workflow saved') as string); + }, + onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)), + }); + + if (isLoading) return
; + + return ( +
+
+ + setName(e.target.value)} + className="px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100" + placeholder={t('workflows.editor.namePlaceholder', 'Workflow name') as string} + /> + + +
+ +
+
+ +
+ {PALETTE.map((type) => ( + + ))} +
+ +
+
+ setSelectedId(n.id)} fitView + > + + + + +
+ + {selectedNode && ( +
+
+
{(selectedNode.data as any).nodeType} · {selectedNode.id}
+ +
+ +