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:
Generated
+233
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "3.47.2-beta.0",
|
"version": "3.69.0-beta.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "3.47.2-beta.0",
|
"version": "3.69.0-beta.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fullcalendar/core": "^6.1.20",
|
"@fullcalendar/core": "^6.1.20",
|
||||||
"@fullcalendar/daygrid": "^6.1.20",
|
"@fullcalendar/daygrid": "^6.1.20",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/react-google-recaptcha": "^2.1.9",
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
|
"@xyflow/react": "^12.11.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
@@ -3460,6 +3461,55 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3996,6 +4046,48 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -4435,6 +4527,12 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/cli-cursor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
@@ -4634,6 +4732,111 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
||||||
@@ -9297,6 +9500,34 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/lodash": "^4.17.20",
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/react-google-recaptcha": "^2.1.9",
|
"@types/react-google-recaptcha": "^2.1.9",
|
||||||
|
"@xyflow/react": "^12.11.1",
|
||||||
"axios": "1.15.2",
|
"axios": "1.15.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ import { QuoteResponsePage } from './pages/public/QuoteResponsePage';
|
|||||||
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
import { ContractResponsePage } from './pages/public/ContractResponsePage';
|
||||||
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
import { ProjectsListPage } from './pages/admin/projects/ProjectsListPage';
|
||||||
import { ProjectCockpitPage } from './pages/admin/projects/ProjectCockpitPage';
|
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 { ContractsListPage } from './pages/admin/contracts/ContractsListPage';
|
||||||
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
|
import { ContractEditorPage } from './pages/admin/contracts/ContractEditorPage';
|
||||||
import { ContractDetailPage } from './pages/admin/contracts/ContractDetailPage';
|
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" element={<Navigate to="/admin/clients/accounts" replace />} />
|
||||||
<Route path="customers/:id" element={<RedirectCustomerDetail />} />
|
<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="settings" element={<SettingsPage />} />
|
||||||
<Route path="system-health" element={<SystemHealthPage />} />
|
<Route path="system-health" element={<SystemHealthPage />} />
|
||||||
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
<Route path="webhooks/:id/deliveries" element={<WebhookDeliveriesPage />} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
Landmark,
|
Landmark,
|
||||||
|
Workflow,
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -110,6 +111,12 @@ const navigation: NavItem[] = [
|
|||||||
permission: 'accounting.view',
|
permission: 'accounting.view',
|
||||||
featureFlag: 'accounting',
|
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 }) => {
|
export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, collapsed = false, onToggleCollapse }) => {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"clients": "CRM",
|
"clients": "CRM",
|
||||||
"accounting": "Buchhaltung",
|
"accounting": "Buchhaltung",
|
||||||
|
"workflows": "Workflows",
|
||||||
"betaTag": "Beta"
|
"betaTag": "Beta"
|
||||||
},
|
},
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
|
|||||||
@@ -199,6 +199,7 @@
|
|||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"clients": "CRM",
|
"clients": "CRM",
|
||||||
"accounting": "Accounting",
|
"accounting": "Accounting",
|
||||||
|
"workflows": "Workflows",
|
||||||
"betaTag": "Beta"
|
"betaTag": "Beta"
|
||||||
},
|
},
|
||||||
"archives": {
|
"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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user