feat(workflows): React Flow canvas editor + list + approvals UI

Adds the admin Workflows surface (top-level nav, gated by the workflows
flag + workflows.view): a list page (enable toggle, delete, new), a
pending-approvals inbox (confirm/deny), and a React Flow (@xyflow/react)
canvas editor — palette to add nodes, drag handle→handle to connect
(branch/gate/loop expose yes-no / confirm-deny / loop-exit handles), a
side-panel JSON config editor, and save (writes a new version). Routes +
sidebar entry + workflows.service. Build + tsc clean.

NOTE: the workflow page strings render via inline English fallbacks; DE
translations for the workflows.* block are still pending native review.
This commit is contained in:
Luca
2026-06-23 02:51:40 +02:00
parent 9b557efbf3
commit 5c0396d0c1
10 changed files with 791 additions and 2 deletions
+233 -2
View File
@@ -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
}
}
}
}
}
+1
View File
@@ -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",
+11
View File
@@ -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() {
<Route path="customers" element={<Navigate to="/admin/clients/accounts" replace />} />
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
{/* Workflows (automation engine) — top-level area gated
by the `workflows` flag. */}
<Route element={<RequireFeature flag="workflows" />}>
<Route path="workflows" element={<WorkflowsListPage />} />
<Route path="workflows/approvals" element={<WorkflowApprovalsPage />} />
<Route path="workflows/:id" element={<WorkflowEditorPage />} />
</Route>
<Route path="settings" element={<SettingsPage />} />
<Route path="system-health" element={<SystemHealthPage />} />
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
@@ -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<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
+1
View File
@@ -199,6 +199,7 @@
"calendar": "Kalender",
"clients": "CRM",
"accounting": "Buchhaltung",
"workflows": "Workflows",
"betaTag": "Beta"
},
"eventTypes": {
+1
View File
@@ -199,6 +199,7 @@
"calendar": "Calendar",
"clients": "CRM",
"accounting": "Accounting",
"workflows": "Workflows",
"betaTag": "Beta"
},
"archives": {
@@ -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 (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.approvals.title', 'Approvals')}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.approvals.subtitle', 'Workflow runs waiting on your confirmation.')}</p>
</div>
</div>
<Card padding="none">
{isLoading ? (
<div className="p-10"><Loading /></div>
) : !approvals || approvals.length === 0 ? (
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.approvals.empty', 'Nothing waiting for you right now.')}</div>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{approvals.map((a) => (
<li key={a.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="text-sm text-neutral-900 dark:text-neutral-100">{promptOf(a)}</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{a.workflow_name}
{a.entity_type ? ` · ${a.entity_type} #${a.entity_id}` : ''}
{a.created_at ? ` · ${formatDateTime(a.created_at)}` : ''}
</div>
</div>
<Button variant="outline" size="sm" isLoading={actMutation.isPending} onClick={() => actMutation.mutate({ id: a.id, action: 'confirm' })} leftIcon={<Check className="w-4 h-4" />}>
{t('workflows.approvals.confirm', 'Confirm')}
</Button>
<Button variant="ghost" size="sm" onClick={() => actMutation.mutate({ id: a.id, action: 'deny' })} leftIcon={<X className="w-4 h-4" />}>
{t('workflows.approvals.deny', 'Deny')}
</Button>
</li>
))}
</ul>
)}
</Card>
</div>
);
};
@@ -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<string, string> = {
trigger: '#1D9E75', condition: '#BA7517', branch: '#BA7517', loop: '#378ADD',
wait: '#888780', action: '#534AB7', gate: '#7F77DD', webhook: '#888780',
};
const SOURCE_HANDLES: Record<string, string[]> = {
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 (
<div style={{ borderColor: color }} className="rounded-md border-2 bg-white dark:bg-neutral-900 px-3 py-2 min-w-[140px] text-center shadow-sm">
{data.nodeType !== 'trigger' && <Handle type="target" position={Position.Top} />}
<div className="text-[10px] uppercase tracking-wide" style={{ color }}>{data.nodeType}</div>
<div className="text-sm text-neutral-900 dark:text-neutral-100">{data.label}</div>
{handles ? (
handles.map((h, i) => (
<Handle key={h} id={h} type="source" position={Position.Bottom} style={{ left: `${(100 / (handles.length + 1)) * (i + 1)}%` }}>
</Handle>
))
) : (
<Handle type="source" position={Position.Bottom} />
)}
</div>
);
}
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<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [name, setName] = useState('');
const [triggerType, setTriggerType] = useState('invoice.sent');
const [enabled, setEnabled] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(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<string, unknown>;
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 <div className="p-10"><Loading /></div>;
return (
<div className="space-y-3">
<div className="flex items-center gap-3 flex-wrap">
<Button variant="ghost" size="sm" onClick={() => navigate('/admin/workflows')} aria-label={t('common.back', 'Back') as string}>
<ArrowLeft className="w-4 h-4" />
</Button>
<input
value={name} onChange={(e) => 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}
/>
<select
value={triggerType} onChange={(e) => setTriggerType(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 text-sm"
>
{TRIGGERS.map((tr) => <option key={tr} value={tr}>{tr}</option>)}
</select>
<label className="text-sm text-neutral-700 dark:text-neutral-300 flex items-center gap-1.5">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} />
{t('workflows.enabled', 'Enabled')}
</label>
<div className="ml-auto">
<Button variant="primary" isLoading={saveMutation.isPending} onClick={() => saveMutation.mutate()} leftIcon={<Save className="w-4 h-4" />}>
{t('common.saveChanges', 'Save changes')}
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{PALETTE.map((type) => (
<button
key={type} type="button" onClick={() => addNode(type)}
className="text-xs px-2 py-1 rounded border border-neutral-300 dark:border-neutral-600 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-800"
>
+ {type}
</button>
))}
</div>
<div className="flex gap-3" style={{ height: '70vh' }}>
<div className="flex-1 rounded-lg border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<ReactFlow
nodes={nodes} edges={edges} nodeTypes={nodeTypes}
onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onConnect={onConnect}
onNodeClick={(_, n) => setSelectedId(n.id)} fitView
>
<Background />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
{selectedNode && (
<div className="w-72 rounded-lg border border-neutral-200 dark:border-neutral-700 p-3 space-y-2 bg-white dark:bg-neutral-900">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{(selectedNode.data as any).nodeType} · {selectedNode.id}</div>
<Button variant="ghost" size="sm" onClick={deleteSelected} aria-label={t('common.delete', 'Delete') as string}>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</Button>
</div>
<label className="block text-xs text-neutral-500 dark:text-neutral-400">{t('workflows.editor.config', 'Config (JSON)')}</label>
<textarea
value={configText} onChange={(e) => setConfigText(e.target.value)} rows={10}
className="w-full text-xs font-mono p-2 rounded border border-neutral-300 dark:border-neutral-600 bg-neutral-50 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
/>
<Button variant="outline" size="sm" onClick={applyConfig}>{t('workflows.editor.applyConfig', 'Apply config')}</Button>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,135 @@
/**
* Admin → Workflows list. Shows every automation flow with its trigger and
* enabled state, a quick enable toggle, edit (canvas) and delete. "New
* workflow" mints a minimal trigger→action graph and opens the editor. A
* pending-approvals shortcut sits in the header.
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common';
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload } from '../../../services/workflows.service';
const NEW_WORKFLOW: WorkflowSavePayload = {
name: 'New workflow',
trigger_type: 'invoice.sent',
enabled: false,
nodes: [
{ node_key: 'trigger', type: 'trigger', pos_x: 240, pos_y: 40 },
{ node_key: 'step1', type: 'action', config: { action: 'noop' }, pos_x: 240, pos_y: 200 },
],
edges: [{ from_node: 'trigger', to_node: 'step1' }],
};
export const WorkflowsListPage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const { data: workflows, isLoading } = useQuery({
queryKey: ['workflows'],
queryFn: () => workflowsService.list(),
});
const createMutation = useMutation({
mutationFn: () => workflowsService.create(NEW_WORKFLOW),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ['workflows'] });
navigate(`/admin/workflows/${res.id}`);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.createFailed', 'Could not create workflow') as string)),
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => workflowsService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['workflows'] });
toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string);
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)),
});
const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;
const isBuiltin = (w: WorkflowSummary) => w.is_builtin === true || w.is_builtin === 1;
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-accent-soft text-on-accent-soft flex items-center justify-center">
<WorkflowIcon className="w-5 h-5" />
</div>
<div>
<h1 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('workflows.title', 'Workflows')}</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('workflows.subtitle', 'Visual automations — triggers, conditions, gates and actions.')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => navigate('/admin/workflows/approvals')} leftIcon={<Inbox className="w-4 h-4" />}>
{t('workflows.approvals.title', 'Approvals')}
</Button>
<Button variant="primary" isLoading={createMutation.isPending} onClick={() => createMutation.mutate()} leftIcon={<Plus className="w-4 h-4" />}>
{t('workflows.new', 'New workflow')}
</Button>
</div>
</div>
<Card padding="none">
{isLoading ? (
<div className="p-10"><Loading /></div>
) : !workflows || workflows.length === 0 ? (
<div className="p-10 text-center text-neutral-500 dark:text-neutral-400">{t('workflows.empty', 'No workflows yet. Create one to automate your invoicing and booking steps.')}</div>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-700">
{workflows.map((w) => (
<li key={w.id} className="flex items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<Link to={`/admin/workflows/${w.id}`} className="font-medium text-neutral-900 dark:text-neutral-100 truncate hover:underline">{w.name}</Link>
{isBuiltin(w) && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-300">{t('workflows.builtin', 'built-in')}</span>
)}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">
{t('workflows.triggerLabel', 'Trigger')}: <code>{w.trigger_type}</code> · v{w.version}
</div>
</div>
<button
type="button"
onClick={() => toggleMutation.mutate({ id: w.id, enabled: !isEnabled(w) })}
className={`text-xs px-2 py-1 rounded-full border ${isEnabled(w)
? 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700'
: 'bg-neutral-50 dark:bg-neutral-800 text-neutral-500 dark:text-neutral-400 border-neutral-300 dark:border-neutral-600'}`}
>
{isEnabled(w) ? t('workflows.enabled', 'Enabled') : t('workflows.disabled', 'Disabled')}
</button>
<Button variant="ghost" size="sm" onClick={() => navigate(`/admin/workflows/${w.id}`)} aria-label={t('common.edit', 'Edit') as string}>
<Pencil className="w-4 h-4" />
</Button>
{!isBuiltin(w) && (
<Button
variant="ghost"
size="sm"
onClick={() => { if (window.confirm(t('workflows.confirmDelete', 'Delete this workflow?') as string)) deleteMutation.mutate(w.id); }}
aria-label={t('common.delete', 'Delete') as string}
>
<Trash2 className="w-4 h-4 text-red-600 dark:text-red-400" />
</Button>
)}
</li>
))}
</ul>
)}
</Card>
</div>
);
};
@@ -0,0 +1,92 @@
import { api } from '../config/api';
export type WorkflowNodeType =
| 'trigger' | 'condition' | 'branch' | 'loop' | 'wait' | 'action' | 'gate' | 'webhook';
export interface WorkflowNode {
id?: number;
node_key: string;
type: WorkflowNodeType;
config?: Record<string, unknown>;
pos_x?: number;
pos_y?: number;
}
export interface WorkflowEdge {
id?: number;
from_node: string;
from_handle?: string | null;
to_node: string;
label?: string | null;
loop_back?: boolean;
}
export interface WorkflowSummary {
id: number;
name: string;
description?: string | null;
enabled: boolean | number;
version: number;
trigger_type: string;
is_builtin?: boolean | number;
builtin_key?: string | null;
}
export interface WorkflowDetail extends WorkflowSummary {
trigger_config?: Record<string, unknown> | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export interface WorkflowRun {
id: number;
workflow_id: number;
version: number;
trigger_event: string;
entity_type?: string | null;
entity_id?: number | null;
status: string;
current_node?: string | null;
started_at?: string;
finished_at?: string | null;
error?: string | null;
}
export interface WorkflowApproval {
id: number;
type: string;
payload: Record<string, unknown>;
created_at: string;
expires_at?: string | null;
run_id: number;
entity_type?: string | null;
entity_id?: number | null;
workflow_id: number;
workflow_name: string;
}
export interface WorkflowSavePayload {
name: string;
description?: string | null;
enabled?: boolean;
trigger_type: string;
trigger_config?: Record<string, unknown> | null;
nodes: WorkflowNode[];
edges: WorkflowEdge[];
}
export const workflowsService = {
list: async (): Promise<WorkflowSummary[]> => (await api.get<WorkflowSummary[]>('/admin/workflows')).data,
get: async (id: number): Promise<WorkflowDetail> => (await api.get<WorkflowDetail>(`/admin/workflows/${id}`)).data,
create: async (payload: WorkflowSavePayload): Promise<{ id: number }> =>
(await api.post<{ id: number }>('/admin/workflows', payload)).data,
update: async (id: number, payload: WorkflowSavePayload): Promise<{ id: number; version: number }> =>
(await api.put<{ id: number; version: number }>(`/admin/workflows/${id}`, payload)).data,
setEnabled: async (id: number, enabled: boolean) =>
(await api.patch(`/admin/workflows/${id}/enabled`, { enabled })).data,
remove: async (id: number) => (await api.delete(`/admin/workflows/${id}`)).data,
runs: async (id: number): Promise<WorkflowRun[]> => (await api.get<WorkflowRun[]>(`/admin/workflows/${id}/runs`)).data,
approvals: async (): Promise<WorkflowApproval[]> => (await api.get<WorkflowApproval[]>('/admin/workflows/approvals')).data,
actApproval: async (id: number, action: 'confirm' | 'deny') =>
(await api.post(`/admin/workflows/approvals/${id}/${action}`)).data,
};