Compare commits

..

172 commits
HEAD ... main

Author SHA1 Message Date
5f47dd395c Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-09 23:55:04 +02:00
5109279ebd fix: resolve TypeScript build errors
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m31s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 23:40:43 +02:00
6520763736 automation fixautomation fixeses
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
2026-06-09 22:59:45 +02:00
7eb305f910 cp adapted to 2026 poweron
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 56s
2026-06-09 09:53:38 +02:00
a13a158c67 cleaned servicebag and removed servicehub
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 51s
2026-06-08 23:35:38 +02:00
cd14babb2e refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
2026-06-08 10:31:24 +02:00
d398907edc before refactory workflowAutomation
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-07 22:26:22 +02:00
49c3cf7290 fix(mobile): compact FormGenerator header on connector page
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m34s
Reduce page padding, hide subtitle and filters on mobile, keep pagination horizontal. Prevents controls from consuming all viewport height on small screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 08:04:03 +02:00
19a39bc443 Merge branch 'int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
2026-06-04 23:57:19 +02:00
7a914ce2d9 fix: resolve datasource labels on reload using backend labels
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m19s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 23:56:58 +02:00
b21fa78665 Merge pull request 'int' (#5) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 50s
Reviewed-on: #5
2026-06-04 19:42:27 +00:00
78457a7d27 Merge branch 'int' of ssh://git.poweron.swiss:2222/PowerOn/ui-nyla into int
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-04 21:38:45 +02:00
31ed78e863 fix infomaniak 2026-06-04 21:38:42 +02:00
Stephan Schellworth
059bbe956a fix(flow-editor): encode connection id in browse and ClickUp API paths
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m17s
URL-encode connection references with spaces/colons so ClickUp list browse resolves correctly. Surface browse error field in formatApiError.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:24:04 +02:00
Stephan Schellworth
b36b303def feat(flow-editor): add ClickUp hierarchical list picker for clickupList fields
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m21s
Replaces the text-only FolderPicker stub with browse-based list selection, auto-patches teamId on searchTasks, and adds path utility tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 08:53:49 +02:00
30db1b8316 Merge pull request 'security and mfa' (#4) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Reviewed-on: #4
2026-06-03 21:25:44 +00:00
4475a45a26 security and mfa
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m22s
2026-06-03 23:21:33 +02:00
5ce871fb3c Merge pull request 'fixes doc generation and renderers' (#3) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 47s
Reviewed-on: #3
2026-06-03 15:03:00 +00:00
59b1e1f6a7 fixes doc generation and renderers
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-03 16:45:22 +02:00
991952dde9 Merge pull request 'int' (#2) from int into main
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Reviewed-on: #2
2026-06-03 08:27:42 +00:00
7876a528f5 fixes deploy
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m23s
2026-06-03 10:18:03 +02:00
ff68307a39 fixes private model and udb scoping sources
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 55s
2026-06-03 09:35:59 +02:00
beeed79aaa fix: TDZ crash in useSpeechAudioCapture - move statusRef+_setStatusTracked before stop
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:26:40 +02:00
2031c87529 fix: TDZ crash - move voiceStream/buildPromptFromRefs before _handleSend
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 28s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:23:31 +02:00
e94420dfe9 fix: UDB icons more compact (15px), remove maxWidth limit (450->800), tighter row gap
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 30s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 09:11:26 +02:00
aa982680fa fix: STT recording lifecycle - stop on send, sync voiceActive with hook status, fix mobile re-record
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Failing after 29s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 52s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 08:43:03 +02:00
e727996a18 Merge remote-tracking branch 'origin/int'
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-06-01 00:01:47 +02:00
5711450606 fix: UDB compact layout, mobile table view, DataSource ID attach
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 00:00:38 +02:00
Ida
76f35a7f22 feat: visible progress during file upload
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-05-29 07:06:30 +02:00
Ida
5aacf17b13 feat: Filter zurücksetzen button 2026-05-29 07:06:30 +02:00
Ida
ece5f17e2a feat: New Chat, moved new chat button, fixed reloading animation to be more user friendly 2026-05-29 07:06:30 +02:00
Ida
8e67efa092 feat: Bot antowrten im Vollbild 2026-05-29 07:06:30 +02:00
Ida
9d081e8819 fix: node inhalt extrahieren nimmt jetzt context, files page formgenerator und folder tree zeigen jetzt die gleichen elemente 2026-05-29 07:06:30 +02:00
Ida
1c539076e5 fix: canvas loop bug and node placement 2026-05-29 07:06:30 +02:00
7da7ad5041 auto-refresh teamsbot session list every 10s to catch new/changed sessions
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:54:29 +02:00
6c319a4170 make AdminDatabaseHealthPage persistent via keepAlive
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:17:15 +02:00
bf4916b447 add sendToChat button to SourcesTab for mobile usage
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 00:13:47 +02:00
ab5ead3416 fix db import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m30s
2026-05-28 11:25:24 +02:00
275b5125c1 ui progress db import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m27s
2026-05-28 09:12:24 +02:00
57319507bb streaming export with log
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-27 23:05:00 +02:00
f27bfd2221 db-export streaming
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
2026-05-27 19:37:37 +02:00
8f9d233d8c fixed db export
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 45s
2026-05-27 18:07:04 +02:00
5a5d24bbe2 fix db sync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 48s
2026-05-27 17:43:53 +02:00
639cac2e33 fixes udb
Some checks failed
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
Deploy Nyla Frontend to Integration / deploy (push) Failing after 2s
2026-05-27 16:48:52 +02:00
0331a59da3 config fix
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 17:35:52 +02:00
3cc2f4decf db fixed import
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 46s
2026-05-25 16:30:35 +02:00
12868fdd17 Pydantic FK als Single Source of Truth
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 15:14:09 +02:00
50c05e91d7 db backup-restore with fk
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-25 14:33:50 +02:00
036e6a38db fixed db stream upload
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 43s
2026-05-24 14:59:05 +02:00
8d24d57719 fixed db download
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
2026-05-24 08:59:46 +02:00
554d798ae2 dbsync
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 44s
2026-05-24 08:15:06 +02:00
9047304934 fix: use printf for SSH key to preserve trailing newline
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 49s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:22:40 +02:00
7a228f0181 fix: rewrite workflows for Infomaniak SSH deploy, fix API URLs
All checks were successful
Deploy Nyla Frontend to Production / deploy (push) Successful in 51s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 03:03:47 +02:00
a7921d409e fix: use full GitHub URLs for Azure actions (not mirrored on Forgejo)
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:42:29 +02:00
077dbca759 fix: add env file cleanup step to ui-nyla workflows
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:40:42 +02:00
6dbf91afb2 refactor: migrate to Forgejo workflows, normalize env file names, remove GitHub Actions
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 1s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 02:34:21 +02:00
f35e22c7f4 Sync: full codebase from GitHub frontend_nyla main
Some checks failed
Deploy Nyla Frontend to Production / build-and-deploy (push) Failing after 2s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 23:54:30 +02:00
Ida
234ffa7896 updated deployment file and env file
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m33s
2026-05-22 10:24:04 +02:00
Ida
86a3ac647c fix:wrong base url
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-22 07:53:36 +02:00
Ida
0f1f9781b7 fix: wrong backend on porta instance
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Has been cancelled
2026-05-22 07:52:06 +02:00
Ida
1a4f18392c sudo berechtigung zur rsync installation wieder entfernt
All checks were successful
Deploy Nyla Frontend / build-and-deploy (push) Successful in 10m28s
2026-05-21 08:55:29 +02:00
Ida
9d18e743bc sudo berechtigung zur rsync installation
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m41s
2026-05-21 08:47:08 +02:00
Ida
638f18cd55 rsync vor deployment installieren
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 5m43s
2026-05-21 08:38:02 +02:00
Ida
f1234fedb3 updated deployment workflow to forgejo
Some checks failed
Deploy Nyla Frontend / build-and-deploy (push) Failing after 6m11s
2026-05-21 08:29:02 +02:00
Patrick Motsch
4f2745cc2e
Merge pull request #86 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 2s
Int
2026-05-19 22:35:29 +02:00
ValueOn AG
1308e6d415 fixes rag and workflow 2026-05-19 16:47:52 +02:00
ValueOn AG
65170d9e4c fixed toggle icons udb 2026-05-18 07:57:02 +02:00
ValueOn AG
f37774ff36 db connection pooling and rag limit transparency
Some checks failed
Deploy Nyla Frontend to Integration / build-and-deploy (push) Failing after 17s
2026-05-17 20:38:40 +02:00
Patrick Motsch
ca6261fb1a
Merge pull request #85 from valueonag/int
All checks were successful
Deploy Nyla / deploy (push) Successful in 3s
Int
2026-05-17 00:10:06 +02:00
ValueOn AG
bb441f5268 fixed admin consent msft 2026-05-17 00:07:54 +02:00
Patrick Motsch
e1260c173c
Merge pull request #84 from valueonag/feat/demo-system-readieness
rag enhancements
2026-05-16 23:02:40 +02:00
ValueOn AG
12e10350d9 rag enhancements 2026-05-16 22:55:48 +02:00
Patrick Motsch
de8007644f
Merge pull request #83 from valueonag/feat/demo-system-readieness
upgr node24
2026-05-12 23:46:54 +02:00
ValueOn AG
abdb499067 upgr node24 2026-05-12 23:44:35 +02:00
Patrick Motsch
629333f910
Merge pull request #82 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-12 23:41:03 +02:00
ValueOn AG
8c0e2ee8af build fixes 2026-05-12 23:40:28 +02:00
ValueOn AG
0e89ed2a64 fixes stt paras 2026-05-12 23:33:44 +02:00
ValueOn AG
ba5b0fa8e8 fixes ui 2026-05-12 22:44:20 +02:00
ValueOn AG
230055a4fb teamsbot ux fixes 2026-05-12 22:39:45 +02:00
ValueOn AG
0d8e6501d3 teamsbot auth fixes 2026-05-12 21:31:27 +02:00
ValueOn AG
2ee08c314b teamsbot anonymous bot working 2026-05-12 19:16:28 +02:00
ValueOn AG
ccb2798170 fixed teams 2026-05-12 17:49:44 +02:00
ValueOn AG
a6b37ed684 rag 2026-05-12 15:19:07 +02:00
ValueOn AG
791d575b7d fixed teamsbot issues 2026-05-11 23:59:27 +02:00
ValueOn AG
544f36460a google keys transferred to account poweron.center.ai 2026-05-11 21:26:24 +02:00
Patrick Motsch
5c55312c60
Merge pull request #81 from valueonag/int
Int
2026-05-10 22:24:28 +02:00
Patrick Motsch
9b6edec74e
Merge pull request #80 from valueonag/int
Int
2026-05-10 22:24:04 +02:00
Patrick Motsch
618574bc76
Merge pull request #79 from valueonag/feat/demo-system-readieness
abo enterprise, ai agent fixes
2026-05-10 22:21:09 +02:00
ValueOn AG
98e7679464 abo enterprise, ai agent fixes 2026-05-10 22:09:55 +02:00
Patrick Motsch
3477126d9f
Merge pull request #78 from valueonag/feat/demo-system-readieness
dns switch poweron-center to poweron.swiss
2026-05-08 13:22:30 +02:00
ValueOn AG
f5a5793309 dns switch poweron-center to poweron.swiss 2026-05-08 11:49:24 +02:00
Patrick Motsch
77f4693f4b
Merge pull request #77 from valueonag/feat/demo-system-readieness
merge fixes
2026-05-08 00:43:43 +02:00
ValueOn AG
5c8c80872a merge fixes 2026-05-08 00:42:27 +02:00
Patrick Motsch
a96295490f
Merge pull request #76 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-08 00:29:01 +02:00
Patrick Motsch
bb9fd56bc6
Merge branch 'int' into feat/demo-system-readieness 2026-05-08 00:28:53 +02:00
ValueOn AG
5eb9253fb1 build fixes 2026-05-08 00:15:26 +02:00
ValueOn AG
a912db3fee refactored comcoach und teamsbot 2026-05-06 23:28:15 +02:00
Ida
877b4fec7e fix: when moving folders the targetParentId was always missing, resulting in the folder not being moved 2026-05-06 08:40:33 +02:00
Ida
25b56f585e fix: readded new folder button to folder tree component 2026-05-06 08:19:37 +02:00
Ida
930a34662d fix: falsche gruppierung entfernt, gruppierung richtig implementiert 2026-05-04 17:29:11 +02:00
idittrich-valueon
956a226b1b
Merge pull request #75 from valueonag/int
Int
2026-05-04 14:09:37 +02:00
ValueOn AG
d42fa02736 fix import 2026-05-04 09:33:14 +02:00
ValueOn AG
dca587a2df fixed ux for expand object scrolling 2026-05-04 09:33:14 +02:00
ValueOn AG
79557e51ed fixed component formgeneratortree and truastee workflows 2026-05-04 09:33:14 +02:00
Patrick Motsch
2739b145db
Merge pull request #72 from valueonag/int
Int
2026-05-03 22:47:27 +02:00
Patrick Motsch
73d03b364b
Merge pull request #71 from valueonag/feat/demo-system-readieness
fix import
2026-05-03 22:25:48 +02:00
ValueOn AG
1219d616d4 fix import 2026-05-03 22:24:47 +02:00
Patrick Motsch
1741f497ec
Merge pull request #69 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-03 22:20:45 +02:00
ValueOn AG
0055b8ce44 fixed ux for expand object scrolling 2026-05-03 22:19:20 +02:00
ValueOn AG
d11280fe34 fixed component formgeneratortree and truastee workflows 2026-05-03 22:03:42 +02:00
Ida
3d580a5fca ValueOn Lead to Opportunity durchgespielt, bugfixes im datapicker und node hadover 2026-05-03 18:02:44 +02:00
Ida
1d2d247273 fix: alle Node definitionen korrigiert und im backend gesetzt - keine mapping layer sonder saubere quelldaten, fehlende dataRef parameter hinzugefügt, damit jede node kontext nutzen kann 2026-05-03 15:07:25 +02:00
Patrick Motsch
f6fa57180e
Merge pull request #68 from valueonag/int
Int
2026-05-01 00:01:55 +02:00
Patrick Motsch
992c0472c6
Merge pull request #67 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-01 00:00:30 +02:00
ValueOn AG
02bf4020d7 build fixes 2026-05-01 00:00:06 +02:00
ValueOn AG
c7e94aea79 fixed nodes handovers 2026-04-30 23:54:51 +02:00
Ida
7c05cb0dd7 replaced file tree mit formgenerator gruppierung 2026-04-30 12:40:43 +02:00
Ida
e7a79a3484 UI Verbesserungen Gruppierung und Anwendung auf alle Seiten 2026-04-30 10:46:44 +02:00
ValueOn AG
ad96c6d861 fixes 2026-04-29 23:13:01 +02:00
ValueOn AG
70459d57e3 fixes before document generation refactory styles 2026-04-29 22:54:26 +02:00
ValueOn AG
8cecf3b320 plana+c implemented 2026-04-29 21:27:15 +02:00
Ida
aff9dcb7bd build errors 2026-04-29 18:35:42 +02:00
Ida
31586d62c1 build errors 2026-04-29 18:32:50 +02:00
Ida
c8e9304801 gruppierung fertig gestellt formgenerator 2026-04-29 18:25:42 +02:00
Ida
b61544d8b1 feat: rag extension frontend consent 2026-04-29 14:53:38 +02:00
Patrick Motsch
26958d1e16
Merge pull request #65 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-29 01:56:07 +02:00
ValueOn AG
28951a7d22 fixes infomaniac different than in doc 2026-04-29 00:57:24 +02:00
ValueOn AG
9e08953c44 kdrive fix 2026-04-29 00:35:11 +02:00
Patrick Motsch
a0c2323fe6
Merge pull request #63 from valueonag/feat/demo-system-readieness
fix build
2026-04-27 07:25:50 +02:00
ValueOn AG
34d6c2b83d fix build 2026-04-27 07:25:17 +02:00
Patrick Motsch
3f80d6d434
Merge pull request #62 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-27 00:00:49 +02:00
ValueOn AG
3016806db9 added infomaniak 2026-04-26 23:59:14 +02:00
Patrick Motsch
974c48e24d
Merge pull request #61 from valueonag/int
Int
2026-04-26 23:16:01 +02:00
Patrick Motsch
fe857d5ade
Merge pull request #59 from valueonag/feat/demo-system-readieness
build fix
2026-04-26 23:06:47 +02:00
ValueOn AG
a9e8e8cddd build fix 2026-04-26 23:05:15 +02:00
Patrick Motsch
2994f3a090
Merge pull request #58 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-26 22:54:40 +02:00
ValueOn AG
f0e73b62d2 Graph and data class falignment strict 2026-04-26 22:53:39 +02:00
ValueOn AG
8679cdffcb datamodel sctirc fk logic in one place 2026-04-26 18:11:52 +02:00
ValueOn AG
d8ff3a84d9 fixed user references 2026-04-26 08:57:47 +02:00
ValueOn AG
c47dc67a84 cleanup internal marked exports 2026-04-26 08:31:31 +02:00
ValueOn AG
e09ed758ff teamsbot 2026-04-25 01:13:13 +02:00
ValueOn AG
fc2cce8732 fixes 2026-04-23 23:09:54 +02:00
Patrick Motsch
0270f59d44
Merge pull request #56 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-22 00:00:08 +02:00
ValueOn AG
208f7b63df ui build fix 2026-04-21 23:56:19 +02:00
ValueOn AG
1c2a196192 fixes udb, outlook, workflow 2026-04-21 23:49:50 +02:00
ValueOn AG
c702740714 redmine integrated and fixed 2026-04-21 21:30:15 +02:00
ValueOn AG
0bdaf86153 redmine integration 2026-04-21 18:14:26 +02:00
Patrick Motsch
ebaaef7b4e
Merge pull request #54 from valueonag/feat/demo-system-readieness
fix critical trustee db sync
2026-04-21 10:46:22 +02:00
ValueOn AG
d771d4bc09 fix critical trustee db sync 2026-04-21 10:45:11 +02:00
Ida
4356394fd8 forgejo setup 2026-04-21 10:35:36 +02:00
Ida
02e7701329 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
1c6f1ac435 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
98a14a5394 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
bcc632927d trigger deploy 2026-04-21 10:35:36 +02:00
Ida
c5b27c0fbd trigger deploy 2026-04-21 10:35:36 +02:00
Ida
a9bdb2d4d4 trigger deploy 2026-04-21 10:35:36 +02:00
Ida
893326e51d add forgejo deploy workflow 2026-04-21 10:35:36 +02:00
Patrick Motsch
9093827e7c
Merge pull request #53 from valueonag/feat/demo-system-readieness
udb fix
2026-04-21 08:58:15 +02:00
ValueOn AG
1c4233c7ea udb fix 2026-04-21 08:57:49 +02:00
Patrick Motsch
9e872910f4
Merge pull request #52 from valueonag/int
Int
2026-04-21 07:48:36 +02:00
Patrick Motsch
45ea3ed48b
Merge pull request #51 from valueonag/feat/demo-system-readieness
fixes
2026-04-21 07:46:58 +02:00
ValueOn AG
8e5a01df6d fixes 2026-04-21 07:46:18 +02:00
Patrick Motsch
3997c6ec63
Merge pull request #50 from valueonag/int
Int
2026-04-21 00:57:41 +02:00
Patrick Motsch
3f4a98381d
Merge pull request #49 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-21 00:55:25 +02:00
ValueOn AG
b4574b6a2e fixes 2026-04-21 00:54:57 +02:00
ValueOn AG
46d6ad1dfa Merge branch 'feat/demo-system-readieness' of https://github.com/valueonag/frontend_nyla into feat/demo-system-readieness 2026-04-21 00:50:46 +02:00
ValueOn AG
7d84160cdb data source fixes 2026-04-21 00:50:42 +02:00
Patrick Motsch
629d26c404
Merge pull request #48 from valueonag/int
Int
2026-04-20 19:12:44 +02:00
Patrick Motsch
7c35c7117b
Merge pull request #47 from valueonag/int
Int
2026-04-20 19:11:14 +02:00
Patrick Motsch
99ba9fb607
Merge pull request #46 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-20 19:04:29 +02:00
ValueOn AG
59017138ff feature fixes 2026-04-20 17:51:07 +02:00
ValueOn AG
be748b162c pwg-demo 2026-04-20 00:31:09 +02:00
549 changed files with 47336 additions and 34721 deletions

View file

@ -1,144 +0,0 @@
# Implement RBAC Roles Page
## Overview
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
## Files to Create/Modify
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
- Follow the pattern from `mandateApi.ts`
- Implement all required endpoints:
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
- `fetchRoleOptions()` - GET /api/rbac/roles/options
- `createRole()` - POST /api/rbac/roles
- `updateRole()` - PUT /api/rbac/roles/{roleId}
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
- Follow the exact pattern from `useAdminMandates.ts`
- Create two hooks:
- `useRbacRoles()` - Main hook for data fetching and state management
- Fetch roles with pagination support
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
- Fetch permissions using `checkPermission('DATA', 'Role')`
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
- Implement `ensureAttributesLoaded()` for EditActionButton
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
- Return pagination info, attributes, permissions, and all required functions
- `useRbacRoleOperations()` - Operations hook for CRUD
- `handleRoleDelete()` - Delete with loading state tracking
- `handleRoleCreate()` - Create with error handling
- `handleRoleUpdate()` - Update with error handling
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
- Return error states (deleteError, createError, updateError)
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
- Follow the exact structure from `mandates.ts`
- Import `FaPlus` from `react-icons/fa` for the create button icon
- Create `createRbacRolesHook()` factory function that:
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
- Converts attributes to columns using `attributesToColumns()` helper
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
- Returns all required data for FormGeneratorTable
- Update `rbacRolePageData`:
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
```typescript
headerButtons: [
{
id: 'add-role',
label: 'admin.rbac-role.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-role.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRoleCreate',
successMessage: 'admin.rbac-role.create.success',
errorMessage: 'admin.rbac-role.create.error'
}
}
]
```
- Add table content section with:
- `hookFactory: createRbacRolesHook`
- Action buttons: edit and delete (following mandates pattern)
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
- Configure delete button with proper operation names
- Add permission-based disabled logic
- Keep existing privilege checker (sysadmin only)
### 4. Update Translations: All three locale files
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
- `'admin.rbac-role.action.delete': 'Löschen'`
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Add Role'`
- `'admin.rbac-role.action.edit': 'Edit'`
- `'admin.rbac-role.action.delete': 'Delete'`
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
- `'admin.rbac-role.create.success': 'Role created successfully'`
- `'admin.rbac-role.create.error': 'Error creating role'`
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
- `'admin.rbac-role.action.edit': 'Modifier'`
- `'admin.rbac-role.action.delete': 'Supprimer'`
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
## Implementation Details
### API File Structure
- Use `ApiRequestFunction` type from `useApi`
- Support pagination parameters (page, pageSize, sort, filters, search)
- Handle both paginated and non-paginated responses
- Use `/api/rbac/roles` as base URL
- Use `/api/attributes/Role` for attributes endpoint
### Hook Pattern
- Use `useApiRequest` hook for API calls
- Use `usePermissions` hook for permission checking
- Use `getUserDataCache()` to check authentication before fetching
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
- Filter out non-editable fields (id, readonly fields, etc.)
- Handle options arrays and option references
### Page Configuration Pattern
- Use `attributesToColumns()` helper to convert attributes to column config
- Disable filtering for date/timestamp fields using `isDateTimeType()`
- Configure action buttons with proper field mappings and operation names
- Use permission-based disabled logic for buttons
- Set `entityType: 'Role'` for EditActionButton
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
## Key Dependencies
- `useApiRequest` from `hooks/useApi`
- `usePermissions` from `hooks/usePermissions`
- `fetchAttributes` from `api/attributesApi`
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
- `FormGeneratorTable` component
- `EditActionButton` and `DeleteActionButton` components
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
- `FaPlus` icon from `react-icons/fa`
## Testing Considerations
- Verify all API endpoints are called correctly
- Ensure attributes are fetched from `/api/attributes/Role`
- Verify permission checks work correctly
- Test create, edit, delete operations
- Verify optimistic updates work
- Check that date/timestamp fields are not filterable
- Verify CreateButton appears in header and opens create modal
- Verify translations work in all three languages

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta-int.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin int
git reset --hard origin/int
cp config/env-int.env .env
rm -f config/env-*.env
npm ci
npm run build:int
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -0,0 +1,53 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
SERVER_HOST: porta.poweron.swiss
SERVER_USER: ubuntu
APP_DIR: /srv/nyla/current
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Setup SSH
run: |
mkdir -p ~/.ssh
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
set -e
cd ${{ env.APP_DIR }}
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
git fetch origin main
git reset --hard origin/main
cp config/env-prod.env .env
rm -f config/env-*.env
npm ci
npm run build:prod
"
- name: Health Check
run: |
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
https://${{ env.SERVER_HOST }}/ || echo "000")
if [ "$HTTP_STATUS" = "200" ]; then
echo "Health check passed! (HTTP $HTTP_STATUS)"
else
echo "Health check returned HTTP $HTTP_STATUS"
fi

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Integration
on:
push:
branches:
- int
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy integration environment file
run: |
cp config/.env.int .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for integration
run: npm run build:int
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend-int",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla-int'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
package: ./deploy

View file

@ -1,71 +0,0 @@
name: Deploy Nyla Frontend to Production
on:
push:
branches:
- main
workflow_dispatch:
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Copy production environment file
run: |
cp config/.env.prod .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for production
run: npm run build:prod
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
package: ./deploy

9
.gitignore vendored
View file

@ -30,7 +30,8 @@ dist-ssr
.cursorignore .cursorignore
# Keep environment template files in config/ # Keep environment files in config/ (naming: env-<workflow>.env)
!config/.env.dev !config/env-*.env
!config/.env.int
!config/.env.prod tsc-errors.txt
scripts/i18n_missing_report.md

View file

@ -5,9 +5,9 @@
```mermaid ```mermaid
graph TB graph TB
%% Environment Files %% Environment Files
ENV_DEV[".env.dev<br/>Development"] ENV_DEV["env-poweron-nyla-dev.env<br/>Development"]
ENV_PROD[".env.prod<br/>Production"] ENV_PROD["env-poweron-nyla-prod.env<br/>Production"]
ENV_INT[".env.int<br/>Integration"] ENV_INT["env-poweron-nyla-int.env<br/>Integration"]
%% Configuration System %% Configuration System
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"] CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
@ -114,30 +114,25 @@ The app uses a **dual configuration system** to handle environment variables acr
- **Used by:** Express servers and build scripts - **Used by:** Express servers and build scripts
### Environment Files ### Environment Files
- **`config/.env.dev`** - Development environment variables
- **Why:** Separate config for local development with debug settings
- **How:** Copied to root `.env` by `npm run dev` command
- **Contains:** Local API URLs, debug flags, dev-specific settings
- **`config/.env.prod`** - Production environment variables Naming convention: `env-<workflow-name>.env` — matches the GitHub Actions workflow that uses it.
- **Why:** Production-specific settings (live API URLs, optimized settings)
- **How:** Copied to root `.env` by GitHub Actions workflow
- **Contains:** Production API URLs, security settings, performance configs
- **`config/.env.int`** - Integration environment variables - **`config/env-poweron-nyla-dev.env`** — Local development (localhost gateway)
- **Why:** Testing environment that mirrors production but with test data - **`config/env-poweron-nyla-int.env`** — Integration (used by `poweron_nyla_int` workflow)
- **How:** Copied to root `.env` by integration deployment workflow - **`config/env-poweron-nyla-prod.env`** — Production (used by `poweron_nyla_main` workflow)
- **Contains:** Staging API URLs, test user credentials, integration settings
Each env is copied to root `.env` at build time (by CI or manually for local dev).
### Usage ### Usage
```bash ```bash
# Development (loads .env.dev) # Local development — copy env then start Vite
cp config/env-poweron-nyla-dev.env .env
npm run dev npm run dev
# Production build (loads .env.prod) # Production build (CI copies env-poweron-nyla-prod.env → .env)
npm run build:prod npm run build:prod
# Integration build (loads .env.int) # Integration build (CI copies env-poweron-nyla-int.env → .env)
npm run build:int npm run build:int
``` ```

View file

@ -1,34 +0,0 @@
# Development Environment Configuration
# Frontend Nyla - Development
# API Configuration
VITE_API_BASE_URL="http://localhost:8000/"
VITE_API_TIMEOUT=10000
# Microsoft Entra ID Configuration
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
VITE_ENTRA_REDIRECT_URI=http://localhost:8000/api/msft/auth/callback/
# Application Configuration
VITE_APP_NAME=PowerOn Nyla dev
VITE_APP_VERSION=0.0.0
VITE_APP_ENVIRONMENT=development
# Debug Configuration
VITE_DEBUG=true
VITE_LOG_LEVEL=debug
VITE_ENABLE_CONSOLE_LOGS=true
# Feature Flags
VITE_ENABLE_ANALYTICS=false
VITE_ENABLE_ERROR_REPORTING=false
VITE_ENABLE_PERFORMANCE_MONITORING=false
# Development Server
VITE_DEV_SERVER_PORT=5176
VITE_DEV_SERVER_HOST=localhost
VITE_DEV_SERVER_HTTPS=false

View file

@ -1,33 +0,0 @@
# Integration/Test Environment Configuration
# Frontend Nyla - Integration
# API Configuration
VITE_API_BASE_URL=https://gateway-int.poweron-center.net
VITE_API_TIMEOUT=12000
# Microsoft Entra ID Configuration
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
VITE_ENTRA_REDIRECT_URI=https://gateway-int.poweron-center.net/api/msft/auth/callback/
# Application Configuration
VITE_APP_NAME=Poweron Nyla int
VITE_APP_VERSION=0.0.0
VITE_APP_ENVIRONMENT=integration
# Debug Configuration
VITE_DEBUG=true
VITE_LOG_LEVEL=info
VITE_ENABLE_CONSOLE_LOGS=true
# Feature Flags
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_ERROR_REPORTING=true
VITE_ENABLE_PERFORMANCE_MONITORING=true
# Test Configuration
VITE_ENABLE_MOCK_DATA=false
VITE_ENABLE_TEST_MODE=true

View file

@ -1,33 +0,0 @@
# Production Environment Configuration
# Frontend Nyla - Production
# API Configuration
VITE_API_BASE_URL=https://gateway-prod.poweron-center.net
VITE_API_TIMEOUT=15000
# Microsoft Entra ID Configuration
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
VITE_ENTRA_REDIRECT_URI=https://gateway-prod.poweron-center.net/api/msft/auth/callback/
# Application Configuration
VITE_APP_NAME=PowerOn Nyla
VITE_APP_VERSION=0.0.0
VITE_APP_ENVIRONMENT=production
# Debug Configuration
VITE_DEBUG=false
VITE_LOG_LEVEL=error
VITE_ENABLE_CONSOLE_LOGS=false
# Feature Flags
VITE_ENABLE_ANALYTICS=true
VITE_ENABLE_ERROR_REPORTING=true
VITE_ENABLE_PERFORMANCE_MONITORING=true
# Security Configuration
VITE_ENABLE_HTTPS=true
VITE_ENABLE_CSP=true

View file

@ -1,178 +1,24 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Simple Configuration Service * Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI).
* Centralized access to environment variables with fallbacks *
* NO silent fallbacks for critical values.
* If VITE_API_BASE_URL is missing the app fails loudly at startup.
*
* Vite replaces import.meta.env.VITE_* statically at build time.
* Dynamic access via import.meta.env[key] does NOT work in production builds.
* Therefore each variable must be accessed with its literal property name.
*/ */
// API Configuration const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
export const getApiTimeout = (): number => { if (!_apiBaseUrl) {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000'); throw new Error(
}; 'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
);
}
// App Configuration export const getApiBaseUrl = (): string => _apiBaseUrl;
export const getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getAppVersion = (): string => { export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
return import.meta.env.VITE_APP_VERSION || '0.0.0';
};
export const getAppEnvironment = (): string => {
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
};
// Environment Detection
export const isDevelopment = (): boolean => {
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
};
export const isProduction = (): boolean => {
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
};
export const isIntegration = (): boolean => {
return getAppEnvironment() === 'int';
};
// Debug Configuration
export const isDebugMode = (): boolean => {
return import.meta.env.VITE_DEBUG === 'true';
};
export const getLogLevel = (): string => {
return import.meta.env.VITE_LOG_LEVEL || 'info';
};
export const isConsoleLogsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
};
// Microsoft Authentication
export const getMicrosoftClientId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
};
export const getMicrosoftTenantId = (): string | undefined => {
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
};
export const getEntraClientSecret = (): string | undefined => {
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
};
export const getEntraAuthority = (): string | undefined => {
return import.meta.env.VITE_ENTRA_AUTHORITY;
};
export const getEntraRedirectPath = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
};
export const getEntraRedirectUri = (): string | undefined => {
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
};
// Feature Flags (if needed in the future)
export const isFeatureEnabled = (feature: string): boolean => {
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
return import.meta.env[envKey] === 'true';
};
// Analytics and Monitoring
export const isAnalyticsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
};
export const isErrorReportingEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
};
export const isPerformanceMonitoringEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
};
// Development Server (for dev environment)
export const getDevServerPort = (): number => {
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
};
export const getDevServerHost = (): string => {
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
};
export const isDevServerHttps = (): boolean => {
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
};
// Security Configuration
export const isHttpsEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
};
export const isCspEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_CSP === 'true';
};
// Test Configuration
export const isMockDataEnabled = (): boolean => {
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
};
export const isTestMode = (): boolean => {
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
};
// Convenience object for easy destructuring
export const config = {
// API
getApiBaseUrl,
getApiTimeout,
// App
getAppName,
getAppVersion,
getAppEnvironment,
// Environment
isDevelopment,
isProduction,
isIntegration,
// Debug
isDebugMode,
getLogLevel,
isConsoleLogsEnabled,
// Microsoft Auth
getMicrosoftClientId,
getMicrosoftTenantId,
getEntraClientSecret,
getEntraAuthority,
getEntraRedirectPath,
getEntraRedirectUri,
// Features
isFeatureEnabled,
// Analytics
isAnalyticsEnabled,
isErrorReportingEnabled,
isPerformanceMonitoringEnabled,
// Dev Server
getDevServerPort,
getDevServerHost,
isDevServerHttps,
// Security
isHttpsEnabled,
isCspEnabled,
// Test
isMockDataEnabled,
isTestMode,
};

6
config/env-dev.env Normal file
View file

@ -0,0 +1,6 @@
# Environment: poweron-nyla-dev (local development)
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL="http://localhost:8000"
VITE_APP_NAME=PowerOn Nyla dev

6
config/env-int.env Normal file
View file

@ -0,0 +1,6 @@
# Environment: poweron-nyla-int (integration)
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api-int.poweron.swiss
VITE_APP_NAME=Poweron Nyla int

6
config/env-prod.env Normal file
View file

@ -0,0 +1,6 @@
# Environment: poweron-nyla-prod (production)
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
# Auth and secrets live on the gateway — never in frontend env.
VITE_API_BASE_URL=https://api.poweron.swiss
VITE_APP_NAME=PowerOn Nyla

View file

@ -1,12 +1,3 @@
// Export simple configuration service // Copyright (c) 2026 PowerOn AG
export * from './config'; // All rights reserved.
export { getApiBaseUrl, getAppName } from './config';
// Re-export commonly used functions
export {
getApiBaseUrl,
getAppName,
isDevelopment,
isProduction,
isDebugMode,
config
} from './config';

17
env.d.ts vendored
View file

@ -1,15 +1,8 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_URL: string readonly VITE_API_BASE_URL?: string
readonly VITE_MICROSOFT_CLIENT_ID: string readonly VITE_APP_NAME?: string
readonly VITE_MICROSOFT_TENANT_ID: string }
readonly VITE_ENTRA_CLIENT_SECRET: string
readonly VITE_ENTRA_AUTHORITY: string
readonly VITE_ENTRA_REDIRECT_PATH: string
readonly VITE_ENTRA_REDIRECT_URI: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View file

@ -23,6 +23,12 @@ export default tseslint.config(
'warn', 'warn',
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
'no-restricted-imports': [
'warn',
{
patterns: [],
},
],
}, },
}, },
) )

1654
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,11 +11,13 @@
"build:prod": "tsc -b && vite build --mode prod", "build:prod": "tsc -b && vite build --mode prod",
"build:int": "tsc -b && vite build --mode int", "build:int": "tsc -b && vite build --mode int",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@azure/msal-browser": "^4.12.0",
"@azure/msal-react": "^3.0.12",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/leaflet": "^1.9.21", "@types/leaflet": "^1.9.21",
"@xstate/react": "^5.0.0", "@xstate/react": "^5.0.0",
@ -47,18 +49,24 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.30.1", "@eslint/js": "^9.30.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
"@types/proj4": "^2.5.6", "@types/proj4": "^2.5.6",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.30.1", "eslint": "^9.30.1",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0", "globals": "^16.3.0",
"jsdom": "^25.0.1",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"typescript-eslint": "^8.35.1", "typescript-eslint": "^8.35.1",
"vite": "^5.4.10", "vite": "^5.4.10",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2",
"vitest": "^2.1.9"
} }
} }

View file

@ -185,7 +185,10 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p>
<p><strong>PowerOn AG</strong> · Birmensdorferstrasse 94 · CH-8003 Zürich · Switzerland</p>
<p><a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p style="margin-top: 1rem;">&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -140,7 +140,7 @@
</div> </div>
<div class="last-updated"> <div class="last-updated">
<strong>Last Updated:</strong> August 2025 <strong>Last Updated:</strong> May 2026
</div> </div>
<div class="content-section"> <div class="content-section">
@ -272,8 +272,13 @@
<h2>Contact Us</h2> <h2>Contact Us</h2>
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p> <p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
<div class="highlight-box"> <div class="highlight-box">
<p><strong>Email:</strong> privacy@poweron-ai.com</p> <p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p> <p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -283,7 +288,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p>&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

View file

@ -153,7 +153,7 @@
</div> </div>
<div class="last-updated"> <div class="last-updated">
<strong>Last Updated:</strong> August 2025 <strong>Last Updated:</strong> May 2026
</div> </div>
<div class="content-section"> <div class="content-section">
@ -315,8 +315,13 @@
<h2>Contact Information</h2> <h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p> <p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box"> <div class="highlight-box">
<p><strong>Email:</strong> legal@poweron-ai.com</p> <p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p> <p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -326,7 +331,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p> <p>&copy; 2026 PowerOn. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* App.tsx * App.tsx
* *
@ -25,7 +27,6 @@ import Reset from './pages/Reset';
import { InvitePage } from './pages/InvitePage'; import { InvitePage } from './pages/InvitePage';
// Providers // Providers
import { AuthProvider } from './providers/auth/AuthProvider';
import { ProtectedRoute } from './providers/auth/ProtectedRoute'; import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext'; import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
@ -40,11 +41,12 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store'; import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage'; import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView'; import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage'; import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() { function App() {
// Load saved theme preference and set app name on app mount // Load saved theme preference and set app name on app mount
@ -71,7 +73,6 @@ function App() {
return ( return (
<LanguageProvider> <LanguageProvider>
<AuthProvider>
<ToastProvider> <ToastProvider>
<VoiceCatalogProvider> <VoiceCatalogProvider>
<WorkflowSelectionProvider> <WorkflowSelectionProvider>
@ -125,15 +126,20 @@ function App() {
</Route> </Route>
{/* ============================================== */} {/* ============================================== */}
{/* AUTOMATIONS DASHBOARD */} {/* WORKFLOW AUTOMATION (System-Komponente) */}
{/* ============================================== */} {/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} /> <Route path="workflow-automation" element={<WorkflowAutomationPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */} {/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} /> <Route path="pek" element={<Navigate to="/" replace />} />
<Route path="speech" element={<Navigate to="/" replace />} /> <Route path="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */} {/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */} {/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */} {/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -147,8 +153,7 @@ function App() {
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} /> <Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} /> <Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} /> <Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} /> <Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} /> <Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} /> <Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} /> <Route path="runs" element={<FeatureViewPage view="runs" />} />
@ -158,8 +163,7 @@ function App() {
<Route path="chat" element={<FeatureViewPage view="chat" />} /> <Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} /> <Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} /> <Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} /> <Route path="import-process" element={<FeatureViewPage view="import-process" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} /> <Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} /> <Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} /> <Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
@ -169,24 +173,26 @@ function App() {
<Route path="templates" element={<FeatureViewPage view="templates" />} /> <Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} /> <Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Workspace + Automation2 Editor */} {/* Workspace Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} /> <Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */} {/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} /> <Route path="sessions" element={<FeatureViewPage view="sessions" />} />
<Route path="settings" element={<FeatureViewPage view="settings" />} /> <Route path="settings" element={<FeatureViewPage view="settings" />} />
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
<Route path="modules" element={<FeatureViewPage view="modules" />} />
{/* Neutralization Feature Views */} {/* Neutralization Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} /> <Route path="playground" element={<FeatureViewPage view="playground" />} />
{/* CommCoach Feature Views */} {/* CommCoach Feature Views */}
<Route path="coaching" element={<FeatureViewPage view="coaching" />} /> <Route path="session" element={<FeatureViewPage view="session" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
{/* Redmine Feature Views */}
<Route path="stats" element={<FeatureViewPage view="stats" />} />
<Route path="browser" element={<FeatureViewPage view="browser" />} />
{/* Catch-all für unbekannte Sub-Pfade */} {/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} /> <Route path="*" element={<FeatureViewPage view="not-found" />} />
@ -215,7 +221,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} /> <Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} /> <Route path="languages" element={null} />
<Route path="database-health" element={<AdminDatabaseHealthPage />} /> <Route path="database-health" element={null} />
<Route path="demo-config" element={<AdminDemoConfigPage />} /> <Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} /> <Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} /> <Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
@ -235,7 +241,6 @@ function App() {
</WorkflowSelectionProvider> </WorkflowSelectionProvider>
</VoiceCatalogProvider> </VoiceCatalogProvider>
</ToastProvider> </ToastProvider>
</AuthProvider>
</LanguageProvider> </LanguageProvider>
); );
} }

View file

@ -1,25 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
// api.ts // api.ts
import axios from 'axios'; import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
import { clearUserDataCache, getUserDataCache } from './utils/userCache'; import { clearUserDataCache, getUserDataCache } from './utils/userCache';
// Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
try {
// For localhost, return as is
if (hostname === 'localhost' || hostname === '127.0.0.1') {
return hostname;
}
// For production domains, we can't directly resolve IP due to CORS
// But we can show the hostname which is more useful anyway
return hostname;
} catch (error) {
console.warn('Could not resolve hostname to IP:', error);
return hostname;
}
};
/** /**
* Extract mandate/instance context from current URL. * Extract mandate/instance context from current URL.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/... * URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
@ -44,45 +29,25 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
import { getApiBaseUrl } from '../config/config'; import { getApiBaseUrl } from '../config/config';
const _baseUrl = getApiBaseUrl();
if (import.meta.env.DEV) {
console.log(`[api] Backend: ${_baseUrl} | env: ${import.meta.env.MODE}`);
}
const api = axios.create({ const api = axios.create({
baseURL: getApiBaseUrl(), baseURL: _baseUrl,
withCredentials: true withCredentials: true,
paramsSerializer: { indexes: null },
}); });
// Add a request interceptor to add the auth token, context headers, and log backend IP // Add a request interceptor to add the auth token, context headers
api.interceptors.request.use( api.interceptors.request.use(
async (config) => { async (config) => {
// Log backend information // Add auth token if available (otherwise httpOnly cookies are used automatically)
const backendUrl = config.baseURL || getApiBaseUrl();
console.log(`🌐 Communicating with backend: ${backendUrl}`);
// Try to resolve and log the IP address
if (backendUrl) {
try {
const url = new URL(backendUrl);
const hostname = url.hostname;
const resolvedIP = await resolveHostnameToIP(hostname);
console.log(`📍 Backend hostname: ${hostname}`);
console.log(`🔗 Full backend URL: ${backendUrl}`);
console.log(`🌍 Resolved address: ${resolvedIP}`);
// Log environment info
console.log(`🏗️ Environment: ${import.meta.env.MODE}`);
console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`);
} catch (error) {
console.warn('Could not parse backend URL:', error);
}
}
// Check for auth token in localStorage and add to headers
const authToken = localStorage.getItem('authToken'); const authToken = localStorage.getItem('authToken');
if (authToken && config.headers) { if (authToken && config.headers) {
config.headers.Authorization = `Bearer ${authToken}`; config.headers.Authorization = `Bearer ${authToken}`;
console.log('🔑 Using Bearer token for authentication');
} else {
// Fallback: httpOnly cookies
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
} }
// Send app language to backend so i18n labels match the UI // Send app language to backend so i18n labels match the UI
@ -92,6 +57,20 @@ api.interceptors.request.use(
config.headers['Accept-Language'] = appLanguage; config.headers['Accept-Language'] = appLanguage;
} }
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
// resolve "now" for AI agents and user-visible time strings without
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
if (config.headers) {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone) {
config.headers['X-User-Timezone'] = browserTimezone;
}
} catch {
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
}
}
// Add multi-tenant context headers from URL (if not already set) // Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks // This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl(); const context = getContextFromUrl();

View file

@ -1,4 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper';
export type { AttributeType };
// ============================================================================ // ============================================================================
// TYPES & INTERFACES // TYPES & INTERFACES
@ -7,7 +12,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface AttributeDefinition { export interface AttributeDefinition {
name: string; name: string;
label: string; label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea'; type: AttributeType;
sortable?: boolean; sortable?: boolean;
filterable?: boolean; filterable?: boolean;
searchable?: boolean; searchable?: boolean;

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api'; import api from '../api';
import { addCSRFTokenToHeaders } from '../utils/csrfUtils'; import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
@ -12,14 +14,30 @@ export interface LoginRequest {
} }
export interface LoginResponse { export interface LoginResponse {
type: 'local_auth_success'; type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
accessToken?: string; accessToken?: string;
tokenType?: string; tokenType?: string;
authenticationAuthority?: string; authenticationAuthority?: string;
mfaToken?: string;
provisioningUri?: string;
label?: any; label?: any;
fieldLabels?: any; fieldLabels?: any;
} }
export interface MfaVerifyRequest {
token: string;
code: string;
}
export interface MfaSetupResponse {
provisioningUri: string;
}
export interface MfaStatusResponse {
mfaEnabled: boolean;
mfaRequired: boolean;
}
export interface RegisterData { export interface RegisterData {
username: string; username: string;
email: string; email: string;
@ -316,3 +334,36 @@ export async function logoutApi(): Promise<void> {
await api.post('/api/local/logout'); await api.post('/api/local/logout');
} }
// ============================================================================
// MFA API FUNCTIONS
// ============================================================================
export async function mfaVerifyApi(data: MfaVerifyRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>('/api/mfa/verify', data);
return response.data;
}
export async function mfaSetupApi(): Promise<MfaSetupResponse> {
const response = await api.post<MfaSetupResponse>('/api/mfa/setup');
return response.data;
}
export async function mfaConfirmApi(code: string, token?: string): Promise<{ mfaEnabled: boolean }> {
if (token) {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm', { code, token });
return response.data;
}
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm-authenticated', { code });
return response.data;
}
export async function mfaStatusApi(): Promise<MfaStatusResponse> {
const response = await api.get<MfaStatusResponse>('/api/mfa/status');
return response.data;
}
export async function mfaDisableApi(code: string): Promise<{ mfaEnabled: boolean }> {
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/disable', { code });
return response.data;
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -29,13 +31,36 @@ export interface BillingTransaction {
aicoreProvider?: string; aicoreProvider?: string;
aicoreModel?: string; aicoreModel?: string;
createdByUserId?: string; createdByUserId?: string;
createdAt?: string; sysCreatedAt?: string;
mandateId?: string; mandateId?: string;
mandateName?: string; mandateName?: string;
userId?: string; userId?: string;
userName?: string; userName?: string;
} }
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
export interface BillingTransactionsPaginationParams {
page?: number;
pageSize?: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface BillingTransactionsPaginatedResponse {
items: BillingTransaction[];
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface BillingSettings { export interface BillingSettings {
id: string; id: string;
mandateId: string; mandateId: string;
@ -56,8 +81,12 @@ export interface BillingSettingsUpdate {
rechargeMaxPerMonth?: number; rechargeMaxPerMonth?: number;
} }
export type BillingBucketSize = 'day' | 'month' | 'year';
export interface UsageReport { export interface UsageReport {
period: string; dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
totalCost: number; totalCost: number;
transactionCount: number; transactionCount: number;
costByProvider: Record<string, number>; costByProvider: Record<string, number>;
@ -65,6 +94,12 @@ export interface UsageReport {
costByFeature: Record<string, number>; costByFeature: Record<string, number>;
} }
export interface StatisticsRangeRequest {
dateFrom: string;
dateTo: string;
bucketSize: BillingBucketSize;
}
export interface AccountSummary { export interface AccountSummary {
id: string; id: string;
mandateId: string; mandateId: string;
@ -125,7 +160,31 @@ export async function fetchBalanceForMandate(
} }
/** /**
* Fetch transaction history * Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
* Endpoint: GET /api/billing/transactions?pagination=...
*/
export async function fetchTransactionsPaginated(
request: ApiRequestFunction,
params?: BillingTransactionsPaginationParams
): Promise<BillingTransactionsPaginatedResponse> {
const paginationObj: Record<string, unknown> = {};
if (params?.page !== undefined) paginationObj.page = params.page;
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params?.sort?.length) paginationObj.sort = params.sort;
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
if (params?.search) paginationObj.search = params.search;
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
return await request({
url: '/api/billing/transactions',
method: 'get',
params: { pagination: JSON.stringify(paginationObj) },
});
}
/**
* Fetch transaction history (legacy array window)
* Endpoint: GET /api/billing/transactions * Endpoint: GET /api/billing/transactions
*/ */
export async function fetchTransactions( export async function fetchTransactions(
@ -141,24 +200,21 @@ export async function fetchTransactions(
} }
/** /**
* Fetch usage statistics * Fetch usage statistics for an explicit date range.
* Endpoint: GET /api/billing/statistics/{period} * Endpoint: GET /api/billing/statistics
*/ */
export async function fetchStatistics( export async function fetchStatistics(
request: ApiRequestFunction, request: ApiRequestFunction,
period: 'day' | 'month' | 'year', range: StatisticsRangeRequest
year: number,
month?: number
): Promise<UsageReport> { ): Promise<UsageReport> {
const params: Record<string, any> = { year };
if (month !== undefined) {
params.month = month;
}
return await request({ return await request({
url: `/api/billing/statistics/${period}`, url: '/api/billing/statistics',
method: 'get', method: 'get',
params params: {
dateFrom: range.dateFrom,
dateTo: range.dateTo,
bucketSize: range.bucketSize,
},
}); });
} }
@ -225,6 +281,19 @@ export async function addCreditAdmin(
}); });
} }
/**
* Fetch the server-side allow-list of CHF top-up amounts
* Endpoint: GET /api/billing/checkout/amounts
*/
export async function fetchCheckoutAmounts(
request: ApiRequestFunction
): Promise<number[]> {
return await request({
url: '/api/billing/checkout/amounts',
method: 'get'
});
}
/** /**
* Create Stripe Checkout Session for credit top-up * Create Stripe Checkout Session for credit top-up
* Endpoint: POST /api/billing/checkout/create/{mandateId} * Endpoint: POST /api/billing/checkout/create/{mandateId}

View file

@ -1,329 +0,0 @@
import { ApiRequestOptions } from '../hooks/useApi';
import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface UserInputRequest {
input: string;
workflowId?: string;
files?: Array<{ id: string; name: string }>;
metadata?: Record<string, any>;
}
export interface ChatbotWorkflow {
id: string;
mandateId?: string; // Optional - not in ChatbotConversation
featureInstanceId?: string; // From ChatbotConversation
status: string;
name?: string;
currentRound?: number;
currentTask?: number;
currentAction?: number;
startedAt?: number;
lastActivity?: number;
[key: string]: any;
}
export interface StartChatbotRequest {
prompt: string;
listFileId?: string[];
userLanguage?: string;
workflowId?: string;
metadata?: Record<string, any>;
}
export interface StartChatbotResponse extends ChatbotWorkflow {
// Workflow object returned from start endpoint
}
export interface ChatDataItem {
type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status' | 'chunk';
createdAt?: number;
item?: Message | any;
label?: string; // For status events
content?: string; // For chunk events (token-by-token streaming)
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// Type for SSE event handler
export type SSEEventHandler = (item: ChatDataItem) => void;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Start a new chatbot workflow or continue an existing one with SSE streaming
* Endpoint: POST /api/chatbot/{instanceId}/start/stream
*
* @param instanceId - Feature Instance ID
* @param requestBody - Request body with prompt and optional workflowId
* @param onEvent - Callback function called for each SSE event
* @param onError - Optional error callback
* @param onComplete - Optional completion callback
* @returns Promise that resolves when stream completes
*/
export async function startChatbotStreamApi(
instanceId: string,
requestBody: StartChatbotRequest,
onEvent: SSEEventHandler,
onError?: (error: Error) => void,
onComplete?: () => void
): Promise<void> {
try {
// Prepare request body
console.log('[startChatbotStreamApi] instanceId:', instanceId);
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
const body: any = {
prompt: requestBody.prompt,
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
...(requestBody.metadata && { metadata: requestBody.metadata })
};
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
// Add workflowId to query params if provided
const url = requestBody.workflowId
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
: `/api/chatbot/${instanceId}/start/stream`;
// Get base URL from api instance
const baseURL = api.defaults.baseURL || '';
const fullURL = baseURL + url;
// Prepare headers with authentication and CSRF token
const headers: Record<string, string> = {
'Content-Type': 'application/json'
};
// Add auth token if available
const authToken = localStorage.getItem('authToken');
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
// Add CSRF token for POST requests
if (!getCSRFToken()) {
generateAndStoreCSRFToken();
}
addCSRFTokenToHeaders(headers);
// Use fetch for SSE streaming (POST with body)
const response = await fetch(fullURL, {
method: 'POST',
headers,
body: JSON.stringify(body),
credentials: 'include' // Include cookies for authentication
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// Decode chunk and add to buffer
buffer += decoder.decode(value, { stream: true });
// Process complete SSE messages
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6); // Remove 'data: ' prefix
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
console.log('[SSE] Received event:', item.type, item);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
} else if (line.startsWith(':')) {
// Comment/keepalive line, ignore
continue;
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
const lines = buffer.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const jsonStr = line.slice(6);
if (jsonStr.trim()) {
const item: ChatDataItem = JSON.parse(jsonStr);
onEvent(item);
}
} catch (parseError) {
console.warn('Failed to parse SSE event:', line, parseError);
}
}
}
}
if (onComplete) {
onComplete();
}
} finally {
reader.releaseLock();
}
} catch (error: any) {
console.error('Error in startChatbotStreamApi:', error);
if (onError) {
onError(error instanceof Error ? error : new Error(String(error)));
} else {
throw error;
}
}
}
/**
* Stop a running chatbot workflow
* Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId}
*/
export async function stopChatbotApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<ChatbotWorkflow> {
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
const data = await request({
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
method: 'post'
});
console.log('[stopChatbotApi] Stop response:', data);
return data as ChatbotWorkflow;
}
/**
* Get chatbot threads/workflows
* Endpoint: GET /api/chatbot/{instanceId}/threads
*/
export async function getChatbotThreadsApi(
request: ApiRequestFunction,
instanceId: string,
pagination?: { page?: number; pageSize?: number }
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
const requestParams = paginationParam
? { pagination: paginationParam }
: undefined;
console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: requestParams
}) as any;
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadsApi] Response structure:`, {
hasItems: !!data.items,
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
hasMetadata: !!data.metadata,
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
});
return {
items: Array.isArray(data.items) ? data.items : [],
metadata: data.pagination ?? data.metadata ?? {}
};
}
/**
* Get a specific chatbot thread/workflow with its chat data
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
*
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to fetch
* @returns Object containing workflow details and chatData with items array
*/
export async function getChatbotThreadApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
const data = await request({
url: `/api/chatbot/${instanceId}/threads`,
method: 'get',
params: { workflowId }
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
console.log(`[getChatbotThreadApi] Response structure:`, {
hasWorkflow: !!data.workflow,
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
hasChatData: !!data.chatData,
hasItems: !!data.chatData?.items,
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map((item: ChatDataItem) => item?.type).filter(Boolean) : []
});
return {
workflow: data.workflow,
chatData: data.chatData || { items: [] }
};
}
/**
* Delete a chatbot workflow
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
*
* @param request - API request function
* @param instanceId - Feature Instance ID
* @param workflowId - ID of the workflow to delete
* @returns Success status
*/
export async function deleteChatbotWorkflowApi(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<boolean> {
try {
await request({
url: `/api/chatbot/${instanceId}/${workflowId}`,
method: 'delete'
});
return true;
} catch (error: any) {
console.error('Error deleting chatbot workflow:', error);
throw error;
}
}

135
src/api/clickupApi.ts Normal file
View file

@ -0,0 +1,135 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ClickUp API ClickUp-specific functions for the workflow automation flow editor.
*
* Extracted from the legacy workflowApi.ts re-export shim so each integration
* lives in its own module.
*/
import type { ApiRequestFunction } from './workflowAutomationApi';
function _encodedConnectionId(connectionId: string): string {
return encodeURIComponent(connectionId);
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe". */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
@ -109,8 +111,8 @@ export interface CoachingUserProfile {
} }
export interface DashboardData { export interface DashboardData {
totalContexts: number; totalModules: number;
activeContexts: number; activeModules: number;
totalSessions: number; totalSessions: number;
totalMinutes: number; totalMinutes: number;
streakDays: number; streakDays: number;
@ -122,7 +124,11 @@ export interface DashboardData {
goalProgress?: number; goalProgress?: number;
badges?: CoachingBadge[]; badges?: CoachingBadge[];
level?: { number: number; label: string; totalSessions: number }; level?: { number: number; label: string; totalSessions: number };
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>; modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
/** @deprecated Use totalModules/activeModules/modules instead */
totalContexts?: number;
activeContexts?: number;
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
} }
export interface SSEEvent { export interface SSEEvent {
@ -133,31 +139,73 @@ export interface SSEEvent {
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>; export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
export function getApiRequest(): ApiRequestFunction {
return async (options: ApiRequestOptions<any>) => {
const response = await api(options);
return response.data;
};
}
// ============================================================================ // ============================================================================
// Context API // Module API (TrainingModule — replaces Context API)
// ============================================================================
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
return data.modules || [];
}
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
}): Promise<any> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
return data.module;
}
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
return data;
}
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
return data.module;
}
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
}
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
return data.sessions || [];
}
// ============================================================================
// Context / Module API (uses /modules/ endpoints)
// ============================================================================ // ============================================================================
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> { export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
return data.contexts || []; return data.modules || [];
} }
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: { export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
title: string; description?: string; category?: string; goals?: string[]; title: string; description?: string; category?: string; goals?: string[];
}): Promise<CoachingContext> { }): Promise<CoachingContext> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body }); const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
return data.context; return data.module;
} }
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{ export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[]; context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
}> { }> {
const data = await request({ const data = await request({
url: `/api/commcoach/${instanceId}/contexts/${contextId}`, url: `/api/commcoach/${instanceId}/modules/${contextId}`,
method: 'get', method: 'get',
params: { _t: Date.now() }, params: { _t: Date.now() },
}); });
const ctx = data?.context ?? data; const ctx = data?.module ?? data;
return { return {
context: ctx, context: ctx,
tasks: data?.tasks ?? [], tasks: data?.tasks ?? [],
@ -167,22 +215,22 @@ export async function getContextDetailApi(request: ApiRequestFunction, instanceI
} }
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> { export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
return data.context; return data.module;
} }
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> { export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' }); await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'delete' });
} }
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> { export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
return data.context; return data.module;
} }
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> { export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
return data.context; return data.module;
} }
// ============================================================================ // ============================================================================
@ -192,7 +240,7 @@ export async function activateContextApi(request: ApiRequestFunction, instanceId
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{ export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean; session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
}> { }> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
return data; return data;
} }
@ -207,7 +255,7 @@ export async function startSessionStreamApi(
try { try {
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : ''; const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`; const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
const headers: Record<string, string> = { 'Content-Type': 'application/json' }; const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const authToken = localStorage.getItem('authToken'); const authToken = localStorage.getItem('authToken');
@ -243,14 +291,11 @@ export async function startSessionStreamApi(
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
try { const jsonStr = line.slice(6);
const jsonStr = line.slice(6); if (jsonStr.trim()) {
if (jsonStr.trim()) { let event: SSEEvent;
const event: SSEEvent = JSON.parse(jsonStr); try { event = JSON.parse(jsonStr); } catch { continue; }
onEvent(event); onEvent(event);
}
} catch {
// skip malformed lines
} }
} }
} }
@ -348,14 +393,11 @@ export async function sendMessageStreamApi(
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
try { const jsonStr = line.slice(6);
const jsonStr = line.slice(6); if (jsonStr.trim()) {
if (jsonStr.trim()) { let event: SSEEvent;
const event: SSEEvent = JSON.parse(jsonStr); try { event = JSON.parse(jsonStr); } catch { continue; }
onEvent(event); onEvent(event);
}
} catch {
// skip malformed lines
} }
} }
} }
@ -424,10 +466,12 @@ export async function sendAudioStreamApi(
for (const line of lines) { for (const line of lines) {
if (line.startsWith('data: ')) { if (line.startsWith('data: ')) {
try { const jsonStr = line.slice(6);
const jsonStr = line.slice(6); if (jsonStr.trim()) {
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr)); let event: SSEEvent;
} catch { /* skip */ } try { event = JSON.parse(jsonStr); } catch { continue; }
onEvent(event);
}
} }
} }
} }
@ -446,14 +490,14 @@ export async function sendAudioStreamApi(
// ============================================================================ // ============================================================================
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> { export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'get' });
return data.tasks || []; return data.tasks || [];
} }
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: { export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
title: string; description?: string; priority?: string; dueDate?: string; title: string; description?: string; priority?: string; dueDate?: string;
}): Promise<CoachingTask> { }): Promise<CoachingTask> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'post', data: body }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
return data.task; return data.task;
} }
@ -500,7 +544,14 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> { export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' }); const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
return data.personas || []; return data.items || data.personas || [];
}
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
const queryParams: Record<string, string> = {};
if (params) queryParams.pagination = JSON.stringify(params);
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
return data;
} }
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: { export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
@ -510,10 +561,31 @@ export async function createPersonaApi(request: ApiRequestFunction, instanceId:
return data.persona; return data.persona;
} }
export async function updatePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string, body: {
label?: string; description?: string; gender?: string; systemPromptOverride?: string; isActive?: boolean;
}): Promise<CoachingPersona> {
const data = await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'put', data: body });
return data.persona;
}
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> { export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
} }
// ============================================================================
// Module-Persona Mapping API
// ============================================================================
export async function getModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<string[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'get' });
return data.personaIds || [];
}
export async function setModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string, personaIds: string[]): Promise<string[]> {
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } });
return data.personaIds || [];
}
// ============================================================================ // ============================================================================
// Badge API (Iteration 2) // Badge API (Iteration 2)
// ============================================================================ // ============================================================================
@ -529,7 +601,7 @@ export async function getBadgesApi(request: ApiRequestFunction, instanceId: stri
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string { export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`; return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
} }
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string { export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
@ -544,6 +616,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{ export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string; score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
}>>> { }>>> {
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' }); const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
return data.history || {}; return data.history || {};
} }

View file

@ -1,13 +1,25 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
// TYPES & INTERFACES // TYPES & INTERFACES
// ============================================================================ // ============================================================================
export interface KnowledgePreferences {
schemaVersion?: number;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
maxAgeDays?: number;
}
export interface Connection { export interface Connection {
id: string; id: string;
userId: string; userId: string;
authority: 'local' | 'google' | 'msft' | 'clickup'; authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
externalId: string; externalId: string;
externalUsername: string; externalUsername: string;
externalEmail?: string; externalEmail?: string;
@ -15,6 +27,8 @@ export interface Connection {
connectedAt: number; // Backend uses float for UTC timestamp in seconds connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: number; // Backend uses float for UTC timestamp in seconds lastChecked: number; // Backend uses float for UTC timestamp in seconds
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
[key: string]: any; // Allow additional properties [key: string]: any; // Allow additional properties
} }
@ -37,6 +51,22 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
viewKey?: string;
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface GroupBand {
path: string[];
label: string;
startRowIndex: number;
rowCount: number;
}
export interface GroupLayout {
levels: string[];
bands: GroupBand[];
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -47,17 +77,21 @@ export interface PaginatedResponse<T> {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
}; };
groupLayout?: GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
} }
export interface CreateConnectionData { export interface CreateConnectionData {
id?: string; id?: string;
userId?: string; userId?: string;
authority?: 'msft' | 'google' | 'clickup'; authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
externalId?: string; externalId?: string;
externalUsername?: string; externalUsername?: string;
externalEmail?: string; externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending'; status?: 'active' | 'expired' | 'revoked' | 'pending';
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
connectedAt?: number; connectedAt?: number;
lastChecked?: number; lastChecked?: number;
expiresAt?: number; expiresAt?: number;
@ -103,6 +137,8 @@ export async function fetchConnections(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);
@ -136,14 +172,20 @@ export async function createConnection(
/** /**
* Connect to a service (initiate OAuth) * Connect to a service (initiate OAuth)
* Endpoint: POST /api/connections/{connectionId}/connect * Endpoint: POST /api/connections/{connectionId}/connect
*
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
* Required when newly added scopes (e.g. Calendar/Contacts after a
* feature rollout) need to be granted on top of the existing token.
*/ */
export async function connectService( export async function connectService(
request: ApiRequestFunction, request: ApiRequestFunction,
connectionId: string connectionId: string,
reauth: boolean = false
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
return await request({ return await request({
url: `/api/connections/${connectionId}/connect`, url: `/api/connections/${connectionId}/connect`,
method: 'post' method: 'post',
data: reauth ? { reauth: true } : undefined,
}); });
} }
@ -221,3 +263,235 @@ export async function refreshGoogleToken(
}); });
} }
/**
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
* UserConnection. The backend validates the token via /1/profile and stores it
* as the connection's data-access bearer token.
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
*/
export async function submitInfomaniakToken(
request: ApiRequestFunction,
connectionId: string,
token: string
): Promise<{
id: string;
status: string;
type: string;
externalUsername: string;
externalEmail?: string | null;
lastChecked: number;
}> {
return await request({
url: `/api/infomaniak/connections/${connectionId}/token`,
method: 'post',
data: { token }
});
}
// ============================================================================
// RAG KNOWLEDGE CONSENT & CONTROL
// ============================================================================
export async function patchKnowledgeConsent(
request: ApiRequestFunction,
connectionId: string,
enabled: boolean
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-consent`,
method: 'patch',
data: { enabled }
});
}
export async function patchKnowledgePreferences(
request: ApiRequestFunction,
connectionId: string,
preferences: KnowledgePreferences
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-preferences`,
method: 'patch',
data: { preferences }
});
}
export async function postKnowledgeStop(
request: ApiRequestFunction,
connectionId: string
): Promise<{ connectionId: string; cancelled: number }> {
return await request({
url: `/api/connections/${connectionId}/knowledge-stop`,
method: 'post'
});
}
export interface RagLimits {
maxItems?: number;
maxBytes?: number;
maxFileSize?: number;
maxDepth?: number;
// ClickUp variant
maxTasks?: number;
maxWorkspaces?: number;
maxListsPerWorkspace?: number;
}
export interface DataSourceSettings {
ragLimits?: RagLimits;
}
export interface CostEstimate {
estimatedTokens: number;
estimatedChf: number;
basis: {
kind: string;
limits: Record<string, number>;
assumptions: Record<string, any>;
notes: string;
};
sourceId?: string;
}
export async function patchDataSourceSettings(
request: ApiRequestFunction,
dataSourceId: string,
settings: DataSourceSettings
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
return await request({
url: `/api/datasources/${dataSourceId}/settings`,
method: 'patch',
data: { settings }
});
}
export async function getDataSourceCostEstimate(
request: ApiRequestFunction,
dataSourceId: string
): Promise<CostEstimate> {
return await request({
url: `/api/datasources/${dataSourceId}/cost-estimate`,
method: 'get'
});
}
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
// `UdbSourcesProvider` and the wiki UDB reference page.
// ============================================================================
// RAG INVENTORY
// ============================================================================
export interface RagDataSourceDto {
id: string;
label: string;
path: string;
sourceType: string;
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
ragIndexEnabled: boolean | null;
neutralize: boolean | null;
lastIndexed: number | null;
/** Distinct files indexed for this DataSource (one row per source document). */
fileCount: number;
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
chunkCount: number;
}
export interface RagConnectionDto {
id: string;
authority: string;
externalEmail: string;
knowledgeIngestionEnabled: boolean;
preferences: KnowledgePreferences;
dataSources: RagDataSourceDto[];
totalFiles: number;
totalChunks: number;
runningJobs: {
jobId: string;
progress: number;
/** Already translated server-side. */
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
skippedPolicy: number;
failed: number;
durationMs: number;
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
stoppedAtLimit?: string | null;
/** Effective limits used by the walker, for showing the value next to the limit name. */
limits?: Record<string, number>;
bytesProcessed?: number;
} | null;
}
export interface RagFeatureDataSourceDto {
id: string;
label: string;
tableName: string;
featureCode: string;
ragIndexEnabled: boolean;
}
export interface RagFeatureInstanceDto {
featureInstanceId: string;
featureCode: string;
label: string;
mandateId: string;
fileCount: number;
chunkCount: number;
statusCounts: Record<string, number>;
dataSources: RagFeatureDataSourceDto[];
ragEnabled: boolean;
runningJobs?: {
jobId: string;
progress: number;
progressMessage: string;
}[];
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
lastSuccess?: {
jobId: string;
finishedAt: number | null;
indexed: number;
skippedDuplicate: number;
failed: number;
} | null;
}
export interface RagInventoryDto {
connections: RagConnectionDto[];
featureInstances?: RagFeatureInstanceDto[];
totals: { files: number; chunks: number; bytes?: number };
}
export interface RagActiveJobDto {
jobId: string;
connectionId: string;
connectionLabel?: string;
jobType: string;
progress: number | null;
/** Already translated server-side. */
progressMessage: string;
}
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/me', method: 'get' });
}
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
}
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
}
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Features API * Features API
* *
@ -14,8 +16,6 @@ import type {
InstancePermissions, InstancePermissions,
AccessLevel, AccessLevel,
} from '../types/mandate'; } from '../types/mandate';
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
// ============================================================================= // =============================================================================
// MOCK DATA (Temporär bis Backend bereit) // MOCK DATA (Temporär bis Backend bereit)
// ============================================================================= // =============================================================================
@ -172,56 +172,11 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
} }
try { try {
console.log('📡 featuresApi: Fetching /api/features/my');
const response = await api.get<FeaturesMyResponse>('/api/features/my'); const response = await api.get<FeaturesMyResponse>('/api/features/my');
// Get the actual data (response.data contains the FeaturesMyResponse) // Get the actual data (response.data contains the FeaturesMyResponse)
const data = response.data; const data = response.data;
// DEBUG: Log all chatbot instances and their permissions
console.log('🔍 [DEBUG] featuresApi: Full response received', {
response,
data,
hasMandates: !!data?.mandates,
mandateCount: data?.mandates?.length || 0,
});
if (data?.mandates) {
data.mandates.forEach(mandate => {
mandate.features.forEach(feature => {
if (feature.code === 'chatbot') {
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
mandateId: mandate.id,
mandateName: mandateDisplayLabel(mandate),
featureCode: feature.code,
instanceCount: feature.instances.length,
});
feature.instances.forEach(instance => {
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
instanceId: instance.id,
instanceLabel: instance.instanceLabel,
featureCode: instance.featureCode,
userRoles: instance.userRoles,
permissions: instance.permissions,
views: instance.permissions?.views,
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
instance.permissions?.views?.['_all'],
});
});
}
});
});
}
console.log('✅ featuresApi: Loaded features:', {
mandateCount: data?.mandates?.length || 0,
totalInstances: data?.mandates
?.flatMap(m => m.features)
?.flatMap(f => f.instances)
?.length || 0,
});
return data; return data;
} catch (error) { } catch (error) {
console.error('❌ featuresApi: Error fetching features:', error); console.error('❌ featuresApi: Error fetching features:', error);
@ -239,7 +194,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
return [ return [
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] }, { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] }, { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
]; ];
} }

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -34,6 +36,9 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -44,6 +49,8 @@ export interface PaginatedResponse<T> {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
}; };
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
} }
// Type for the request function passed to API functions // Type for the request function passed to API functions
@ -103,6 +110,9 @@ export async function fetchFiles(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (params.owner) requestParams.owner = params.owner;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);
@ -186,110 +196,72 @@ export async function deleteFiles(
return uniqueIds.map(fileId => ({ success: true, fileId })); return uniqueIds.map(fileId => ({ success: true, fileId }));
} }
export async function deleteFolders(
request: ApiRequestFunction,
folderIds: string[],
recursiveFolders: boolean = true
): Promise<{ deletedFiles: number; deletedFolders: number }> {
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
return await request({
url: '/api/files/batch-delete',
method: 'post',
data: { folderIds: uniqueIds, recursiveFolders }
});
}
// ============================================================================ // ============================================================================
// FOLDER API FUNCTIONS // GROUP BULK API FUNCTIONS
// ============================================================================ // ============================================================================
export interface FolderInfo { /** Patch scope for all files in a group (recursive) */
id: string; export async function patchGroupScope(
name: string;
parentId: string | null;
fileCount?: number;
mandateId?: string;
featureInstanceId?: string;
createdAt?: number;
scope?: string;
neutralize?: boolean;
}
export async function fetchFolders(
request: ApiRequestFunction, request: ApiRequestFunction,
parentId?: string | null groupId: string,
): Promise<FolderInfo[]> { scope: string
const params: any = {};
if (parentId !== undefined && parentId !== null) {
params.parentId = parentId;
}
const data = await request({
url: '/api/files/folders',
method: 'get',
params,
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId || null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string
): Promise<any> { ): Promise<any> {
return await request({ return await request({
url: `/api/files/folders/${folderId}`, url: `/api/files/groups/${groupId}/scope`,
method: 'put', method: 'patch',
data: { name }, data: { scope },
}); });
} }
export async function deleteFolderApi( /** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
export async function patchGroupNeutralize(
request: ApiRequestFunction, request: ApiRequestFunction,
folderId: string, groupId: string,
recursive: boolean = false neutralize: boolean
): Promise<any> { ): Promise<any> {
return await request({ return await request({
url: `/api/files/folders/${folderId}`, url: `/api/files/groups/${groupId}/neutralize`,
method: 'patch',
data: { neutralize },
});
}
/** Download all files in a group as ZIP */
export async function downloadGroupZip(groupId: string): Promise<void> {
const { default: api } = await import('../api');
const response = await api.get(`/api/files/groups/${groupId}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', `group-${groupId}.zip`);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
}
/** Delete a group and optionally all its files */
export async function deleteGroup(
request: ApiRequestFunction,
groupId: string,
deleteItems: boolean = false
): Promise<any> {
return await request({
url: `/api/files/groups/${groupId}`,
method: 'delete', method: 'delete',
params: { recursive }, params: { deleteItems },
}); });
} }
export async function moveFolder( /** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
request: ApiRequestFunction, export function collectGroupItemIds(
folderId: string, _groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
targetParentId: string | null _groupId: string
): Promise<any> { ): string[] {
return await request({ const collect = (): string[] | null => null;
url: `/api/files/folders/${folderId}/move`, return collect() ?? [];
method: 'post',
data: { targetParentId },
});
}
export async function moveFile(
request: ApiRequestFunction,
fileId: string,
targetFolderId: string | null
): Promise<any> {
return await request({
url: `/api/files/${fileId}/move`,
method: 'post',
data: { targetFolderId },
});
} }
// Note: The following operations require special handling (FormData, blob responses) // Note: The following operations require special handling (FormData, blob responses)
@ -299,3 +271,121 @@ export async function moveFile(
// - previewFile: Requires flexible responseType (json or blob) // - previewFile: Requires flexible responseType (json or blob)
// These are kept in the hooks for now due to their special requirements // These are kept in the hooks for now due to their special requirements
// ============================================================================
// FOLDER TYPES & API FUNCTIONS
// ============================================================================
export interface FolderInfo {
id: string;
name: string;
parentId: string | null;
mandateId: string;
featureInstanceId: string;
scope: string;
neutralize: boolean;
contextOrphan?: boolean;
sysCreatedBy?: string;
sysCreatedAt?: number;
sysModifiedAt?: number;
}
export async function getFolderTree(
request: ApiRequestFunction,
owner: 'me' | 'shared' = 'me',
): Promise<FolderInfo[]> {
const data = await request({
url: '/api/files/folders/tree',
method: 'get',
params: { owner },
});
return Array.isArray(data) ? data : [];
}
export async function createFolder(
request: ApiRequestFunction,
name: string,
parentId?: string | null,
): Promise<FolderInfo> {
return await request({
url: '/api/files/folders',
method: 'post',
data: { name, parentId: parentId ?? null },
});
}
export async function renameFolder(
request: ApiRequestFunction,
folderId: string,
name: string,
): Promise<FolderInfo> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'patch',
data: { name },
});
}
export async function moveFolder(
request: ApiRequestFunction,
folderId: string,
parentId: string | null,
): Promise<FolderInfo> {
return await request({
url: `/api/files/folders/${folderId}/move`,
method: 'post',
data: { parentId },
});
}
export async function deleteFolderCascade(
request: ApiRequestFunction,
folderId: string,
): Promise<{ deletedFolders: number; deletedFiles: number }> {
return await request({
url: `/api/files/folders/${folderId}`,
method: 'delete',
params: { cascade: true },
});
}
export async function patchFolderScope(
request: ApiRequestFunction,
folderId: string,
scope: string,
cascadeToFiles: boolean = false,
): Promise<{ folderId: string; scope: string; filesUpdated: number }> {
return await request({
url: `/api/files/folders/${folderId}/scope`,
method: 'patch',
data: { scope, cascadeToFiles },
});
}
export async function patchFolderNeutralize(
request: ApiRequestFunction,
folderId: string,
neutralize: boolean,
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> {
return await request({
url: `/api/files/folders/${folderId}/neutralize`,
method: 'patch',
data: { neutralize },
});
}
export async function moveFiles(
request: ApiRequestFunction,
fileIds: string[],
targetFolderId: string | null,
): Promise<void> {
await Promise.all(
fileIds.map((fileId) =>
request({
url: `/api/files/${fileId}`,
method: 'put',
data: { folderId: targetFolderId },
}),
),
);
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -46,6 +48,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -84,6 +87,7 @@ export async function fetchMandates(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Neutralization API * Neutralization API
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -49,6 +51,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -59,6 +63,8 @@ export interface PaginatedResponse<T> {
totalItems: number; totalItems: number;
totalPages: number; totalPages: number;
}; };
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
} }
export interface CreatePromptData { export interface CreatePromptData {
@ -110,6 +116,8 @@ export async function fetchPrompts(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import type { ApiRequestOptions } from '../hooks/useApi'; import type { ApiRequestOptions } from '../hooks/useApi';

400
src/api/redmineApi.ts Normal file
View file

@ -0,0 +1,400 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Redmine API
*
* Frontend client for the Redmine feature backend.
* URL pattern: /api/redmine/{instanceId}/...
*/
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py
// ============================================================================
export interface RedmineConfigDto {
id?: string;
featureInstanceId: string;
mandateId?: string | null;
baseUrl: string;
projectId: string;
hasApiKey: boolean;
rootTrackerName: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds: number;
schemaCachedAt?: number | null;
isActive: boolean;
lastConnectedAt?: number | null;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorMessage?: string | null;
}
export interface RedmineConfigUpdateRequest {
baseUrl?: string;
projectId?: string;
apiKey?: string;
rootTrackerName?: string;
defaultPeriodValue?: Record<string, any> | null;
schemaCacheTtlSeconds?: number;
isActive?: boolean;
}
export interface RedmineFieldChoice {
id: number;
name: string;
isClosed?: boolean | null;
}
export interface RedmineCustomFieldSchema {
id: number;
name: string;
fieldFormat: string;
isRequired: boolean;
possibleValues: string[];
multiple: boolean;
defaultValue?: string | null;
}
export interface RedmineFieldSchema {
projectId: string;
projectName: string;
trackers: RedmineFieldChoice[];
statuses: RedmineFieldChoice[];
priorities: RedmineFieldChoice[];
users: RedmineFieldChoice[];
categories: RedmineFieldChoice[];
customFields: RedmineCustomFieldSchema[];
rootTrackerName: string;
rootTrackerId: number | null;
}
export interface RedmineRelation {
id: number;
issueId: number;
issueToId: number;
relationType: string;
delay?: number | null;
}
export interface RedmineCustomFieldValue {
id: number;
name: string;
value: any;
}
export interface RedmineTicket {
id: number;
subject: string;
description: string;
trackerId?: number | null;
trackerName?: string | null;
statusId?: number | null;
statusName?: string | null;
isClosed: boolean;
priorityId?: number | null;
priorityName?: string | null;
assignedToId?: number | null;
assignedToName?: string | null;
authorId?: number | null;
authorName?: string | null;
parentId?: number | null;
fixedVersionId?: number | null;
fixedVersionName?: string | null;
categoryId?: number | null;
categoryName?: string | null;
createdOn?: string | null;
updatedOn?: string | null;
customFields: RedmineCustomFieldValue[];
relations: RedmineRelation[];
}
export interface RedmineSyncResult {
instanceId: string;
full: boolean;
ticketsUpserted: number;
relationsUpserted: number;
durationMs: number;
lastSyncAt: number;
error?: string | null;
}
export interface RedmineSyncStatus {
instanceId: string;
lastSyncAt?: number | null;
lastFullSyncAt?: number | null;
lastSyncDurationMs?: number | null;
lastSyncTicketCount?: number | null;
lastSyncErrorAt?: number | null;
lastSyncErrorMessage?: string | null;
mirroredTicketCount: number;
mirroredRelationCount: number;
}
export interface RedmineConnectionTestResult {
ok: boolean;
reason?: string;
message?: string;
status?: number;
user?: { id: number; name: string };
project?: { id: number; name: string };
}
export interface RedmineStats {
instanceId: string;
dateFrom?: string | null;
dateTo?: string | null;
bucket: string;
trackerIds: number[];
categoryIds: number[];
statusFilter: string;
kpis: {
total: number;
open: number;
closed: number;
closedInPeriod: number;
createdInPeriod: number;
orphans: number;
};
statusByTracker: Array<{
trackerId?: number | null;
trackerName: string;
countsByStatus: Record<string, number>;
total: number;
}>;
throughput: Array<{
bucketKey: string;
label: string;
created: number;
closed: number;
cumTotal: number;
cumOpen: number;
}>;
topAssignees: Array<{
assignedToId?: number | null;
name: string;
open: number;
}>;
relationDistribution: Array<{ relationType: string; count: number }>;
backlogAging: Array<{
bucketKey: string;
label: string;
minDays: number;
maxDays?: number | null;
count: number;
}>;
}
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
// ============================================================================
// Config
// ============================================================================
export async function getRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
}
export async function updateRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineConfigUpdateRequest,
): Promise<RedmineConfigDto> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body });
}
export async function deleteRedmineConfigApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<{ deleted: boolean }> {
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' });
}
export async function testRedmineConnectionApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineConnectionTestResult> {
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
}
// ============================================================================
// Schema
// ============================================================================
export async function getRedmineSchemaApi(
request: ApiRequestFunction,
instanceId: string,
forceRefresh = false,
): Promise<RedmineFieldSchema> {
return await request({
url: `${_baseUrl(instanceId)}/schema`,
method: 'get',
params: forceRefresh ? { forceRefresh: true } : undefined,
});
}
// ============================================================================
// Sync
// ============================================================================
export async function runRedmineSyncApi(
request: ApiRequestFunction,
instanceId: string,
force = false,
): Promise<RedmineSyncResult> {
return await request({
url: `${_baseUrl(instanceId)}/sync`,
method: 'post',
params: force ? { force: true } : undefined,
});
}
export async function getRedmineSyncStatusApi(
request: ApiRequestFunction,
instanceId: string,
): Promise<RedmineSyncStatus> {
return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' });
}
// ============================================================================
// Tickets
// ============================================================================
export interface ListTicketsParams {
trackerIds?: number[];
status?: 'open' | 'closed' | '*';
dateFrom?: string;
dateTo?: string;
assignedToId?: number;
}
export async function listRedmineTicketsApi(
request: ApiRequestFunction,
instanceId: string,
params: ListTicketsParams = {},
): Promise<RedmineTicket[]> {
const queryParams: Record<string, any> = {};
if (params.status) queryParams.status = params.status;
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'get',
params: queryParams,
});
}
export async function getRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'get',
});
}
export interface RedmineTicketUpdateBody {
subject?: string;
description?: string;
trackerId?: number;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
notes?: string;
customFields?: Record<number, any>;
}
export async function updateRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
body: RedmineTicketUpdateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'put',
data: body,
});
}
export interface RedmineTicketCreateBody {
subject: string;
trackerId: number;
description?: string;
statusId?: number;
priorityId?: number;
assignedToId?: number;
parentIssueId?: number;
fixedVersionId?: number;
customFields?: Record<number, any>;
}
export async function createRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
body: RedmineTicketCreateBody,
): Promise<RedmineTicket> {
return await request({
url: `${_baseUrl(instanceId)}/tickets`,
method: 'post',
data: body,
});
}
export async function deleteRedmineTicketApi(
request: ApiRequestFunction,
instanceId: string,
issueId: number,
fallbackStatusId?: number,
): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> {
return await request({
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
method: 'delete',
params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined,
});
}
// ============================================================================
// Stats
// ============================================================================
export interface RedmineStatsParams {
dateFrom?: string;
dateTo?: string;
bucket?: 'day' | 'week' | 'month';
trackerIds?: number[];
categoryIds?: number[];
statusFilter?: '*' | 'open' | 'closed';
}
export async function getRedmineStatsApi(
request: ApiRequestFunction,
instanceId: string,
params: RedmineStatsParams = {},
): Promise<RedmineStats> {
const queryParams: Record<string, any> = {};
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
if (params.dateTo) queryParams.dateTo = params.dateTo;
if (params.bucket) queryParams.bucket = params.bucket;
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds;
if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter;
return await request({
url: `${_baseUrl(instanceId)}/stats`,
method: 'get',
params: queryParams,
});
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Store API * Store API
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -42,6 +44,53 @@ export interface MandateSubscription {
snapshotPricePerUserCHF: number; snapshotPricePerUserCHF: number;
snapshotPricePerInstanceCHF: number; snapshotPricePerInstanceCHF: number;
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
isEnterprise?: boolean;
enterpriseFlatPriceCHF?: number | null;
enterpriseMaxUsers?: number | null;
enterpriseMaxFeatureInstances?: number | null;
enterpriseMaxDataVolumeMB?: number | null;
enterpriseBudgetAiCHF?: number | null;
enterpriseNote?: string | null;
}
// ============================================================================
// Enterprise Types
// ============================================================================
export interface EnterpriseCreateParams {
mandateId: string;
startDate: number;
endDate: number;
autoRenew: boolean;
flatPriceCHF: number;
maxUsers?: number | null;
maxFeatureInstances?: number | null;
maxDataVolumeMB?: number | null;
budgetAiCHF?: number | null;
note?: string | null;
}
export interface EnterpriseRenewParams {
subscriptionId: string;
newEndDate: number;
autoRenew?: boolean;
flatPriceCHF?: number;
maxUsers?: number | null;
maxFeatureInstances?: number | null;
maxDataVolumeMB?: number | null;
budgetAiCHF?: number | null;
note?: string | null;
}
export interface EnterpriseUpdateParams {
subscriptionId: string;
enterpriseFlatPriceCHF?: number;
enterpriseMaxUsers?: number | null;
enterpriseMaxFeatureInstances?: number | null;
enterpriseMaxDataVolumeMB?: number | null;
enterpriseBudgetAiCHF?: number | null;
enterpriseNote?: string | null;
recurring?: boolean;
} }
export interface SubscriptionUsage { export interface SubscriptionUsage {
@ -154,3 +203,40 @@ export async function verifyCheckout(
additionalConfig: _mandateConfig(mandateId), additionalConfig: _mandateConfig(mandateId),
}); });
} }
// ============================================================================
// Enterprise API
// ============================================================================
export async function createEnterprise(
request: ApiRequestFunction,
params: EnterpriseCreateParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/create',
method: 'post',
data: params,
});
}
export async function renewEnterprise(
request: ApiRequestFunction,
params: EnterpriseRenewParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/renew',
method: 'post',
data: params,
});
}
export async function updateEnterprise(
request: ApiRequestFunction,
params: EnterpriseUpdateParams,
): Promise<Record<string, unknown>> {
return await request({
url: '/api/subscription/enterprise/update',
method: 'put',
data: params,
});
}

61
src/api/tableViewApi.ts Normal file
View file

@ -0,0 +1,61 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api';
export interface TableListViewRow {
id: string;
userId?: string;
mandateId?: string | null;
contextKey: string;
viewKey: string;
displayName: string;
config: TableViewConfig;
updatedAt?: number;
}
export interface TableViewConfig {
schemaVersion?: number;
filters?: Record<string, unknown>;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
collapsedSectionKeys?: string[];
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
collapsedGroupKeys?: string[];
}
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
params: { contextKey },
});
return Array.isArray(data) ? data : [];
}
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
params: { contextKey },
});
return data;
}
export async function createTableView(payload: {
contextKey: string;
viewKey: string;
displayName: string;
config: TableViewConfig;
}): Promise<TableListViewRow> {
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
return data;
}
export async function updateTableView(
viewId: string,
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
): Promise<TableListViewRow> {
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
return data;
}
export async function deleteTableView(viewId: string): Promise<void> {
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
import type { VoiceOption } from './voiceCatalogApi'; import type { VoiceOption } from './voiceCatalogApi';
@ -9,6 +11,7 @@ export interface TeamsbotSession {
id: string; id: string;
instanceId: string; instanceId: string;
mandateId: string; mandateId: string;
moduleId?: string;
meetingLink: string; meetingLink: string;
botName: string; botName: string;
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error'; status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
@ -70,6 +73,7 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number; triggerCooldownSeconds: number;
contextWindowSegments: number; contextWindowSegments: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
export interface TeamsbotSessionStats { export interface TeamsbotSessionStats {
@ -83,6 +87,7 @@ export interface TeamsbotSessionStats {
export interface StartSessionRequest { export interface StartSessionRequest {
meetingLink: string; meetingLink: string;
botName?: string; botName?: string;
moduleId?: string;
connectionId?: string; connectionId?: string;
joinMode?: TeamsbotJoinMode; joinMode?: TeamsbotJoinMode;
sessionContext?: string; sessionContext?: string;
@ -101,6 +106,7 @@ export interface ConfigUpdateRequest {
triggerCooldownSeconds?: number; triggerCooldownSeconds?: number;
contextWindowSegments?: number; contextWindowSegments?: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
// Voice option type re-exported from the central voice catalog API // Voice option type re-exported from the central voice catalog API
@ -169,11 +175,63 @@ export interface MfaChallengeEvent {
// SSE Event Types // SSE Event Types
export interface TeamsbotSSEEvent { export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed'; type:
| 'transcript'
| 'botResponse'
| 'analysis'
| 'suggestedResponse'
| 'statusChange'
| 'error'
| 'ping'
| 'sessionState'
| 'ttsDeliveryStatus'
| 'mfaChallenge'
| 'mfaResolved'
| 'chatSendFailed'
| 'directorPrompt'
| 'agentRun'
| 'botConnectionState';
data: any; data: any;
timestamp?: string; timestamp?: string;
} }
// =========================================================================
// Director Prompts (private operator instructions during a live meeting)
// =========================================================================
export type DirectorPromptMode = 'oneShot' | 'persistent';
export type DirectorPromptStatus =
| 'queued'
| 'running'
| 'succeeded'
| 'failed'
| 'consumed';
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
export interface DirectorPrompt {
id: string;
sessionId: string;
instanceId: string;
operatorUserId: string;
text: string;
mode: DirectorPromptMode;
fileIds: string[];
status: DirectorPromptStatus;
statusMessage?: string;
createdAt: string;
consumedAt?: string;
agentRunId?: string;
responseText?: string;
}
export interface DirectorPromptCreateRequest {
text: string;
mode: DirectorPromptMode;
fileIds?: string[];
}
// ============================================================================ // ============================================================================
// API FUNCTIONS // API FUNCTIONS
// ============================================================================ // ============================================================================
@ -289,6 +347,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
return response.data; return response.data;
} }
/**
* Create a new system bot account. The password is encrypted server-side
* before storage; the API never returns the password back. SysAdmin only.
*/
export async function createSystemBot(
instanceId: string,
payload: { email: string; password: string; name?: string },
): Promise<{ bot: SystemBot }> {
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
return response.data;
}
/**
* Delete a system bot account. SysAdmin only.
*/
export async function deleteSystemBot(
instanceId: string,
botId: string,
): Promise<{ deleted: boolean }> {
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
return response.data;
}
/** /**
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio. * Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
*/ */
@ -386,6 +467,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
return new EventSource(url, { withCredentials: true }); return new EventSource(url, { withCredentials: true });
} }
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
export function createDashboardStream(instanceId: string): EventSource {
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
return new EventSource(url, { withCredentials: true });
}
// ========================================================================= // =========================================================================
// Debug Screenshots (SysAdmin only) // Debug Screenshots (SysAdmin only)
// ========================================================================= // =========================================================================
@ -452,3 +540,127 @@ export async function submitMfaCode(
}); });
return response.data; return response.data;
} }
// =========================================================================
// Director Prompts
// =========================================================================
/**
* Submit a private director prompt to the running bot. Triggers the full
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
*/
export async function submitDirectorPrompt(
instanceId: string,
sessionId: string,
body: DirectorPromptCreateRequest,
): Promise<{ prompt: DirectorPrompt }> {
const response = await api.post(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
body,
);
return response.data;
}
/**
* List director prompts for a session (operator's own prompts only).
*/
export async function listDirectorPrompts(
instanceId: string,
sessionId: string,
): Promise<{ prompts: DirectorPrompt[] }> {
const response = await api.get(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
);
return response.data;
}
/**
* Remove a (typically persistent) director prompt.
*/
export async function deleteDirectorPrompt(
instanceId: string,
sessionId: string,
promptId: string,
): Promise<{ deleted: boolean; promptId: string }> {
const response = await api.delete(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
);
return response.data;
}
// ============================================================================
// Meeting Module API
// ============================================================================
export interface MeetingModule {
id: string;
instanceId: string;
mandateId: string;
ownerUserId: string;
title: string;
seriesType: string;
defaultBotId?: string;
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string;
}
export async function listModules(instanceId: string): Promise<MeetingModule[]> {
const response = await api.get(`/api/teamsbot/${instanceId}/modules`);
return response.data?.modules || [];
}
export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module;
}
export async function getModuleDetail(instanceId: string, moduleId: string): Promise<{ module: MeetingModule; sessions: TeamsbotSession[] }> {
const response = await api.get(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
return response.data;
}
export async function updateModule(instanceId: string, moduleId: string, body: Partial<MeetingModule>): Promise<MeetingModule> {
const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body);
return response.data?.module;
}
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
}
export interface MediaFileInfo {
id: string;
fileName: string;
mimeType: string;
}
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
const response = await api.get('/api/files/list', {
params: { pagination: JSON.stringify({ pageSize: 500 }) },
});
const data = response.data;
let items: any[];
if (Array.isArray(data)) {
items = data;
} else if (Array.isArray(data?.items)) {
items = data.items;
} else {
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
items = [];
}
const filtered = items.filter((f: any) => {
const mime = (f.mimeType || '').toLowerCase();
return mime.startsWith('image/') || mime.startsWith('video/');
});
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
}

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Trustee API * Trustee API
* *
@ -115,6 +117,7 @@ export interface AccountingConnectorInfo {
secret: boolean; secret: boolean;
required: boolean; required: boolean;
placeholder?: string; placeholder?: string;
suggestions?: string[];
}>; }>;
} }
@ -852,16 +855,53 @@ export async function fetchChartOfAccounts(
}); });
} }
/**
* Submits a background job that pushes positions to the accounting system and
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
* the same `{ total, success, skipped, errors, results }` payload that the
* legacy synchronous endpoint used to return -- but does NOT block the user
* while the (potentially long) external accounting calls run in the worker.
*/
export async function syncPositionsToAccounting( export async function syncPositionsToAccounting(
request: ApiRequestFunction, request: ApiRequestFunction,
instanceId: string, instanceId: string,
positionIds: string[] positionIds: string[],
): Promise<{ total: number; success: number; errors: number; results: any[] }> { opts?: {
return await request({ pollMs?: number;
/**
* `message` is already translated server-side by the job route handler
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
*/
onProgress?: (progress: number, message?: string | null) => void;
}
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`, url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
method: 'post', method: 'post',
data: { positionIds } data: { positionIds }
}); });
const jobId: string | undefined = submission?.jobId;
if (!jobId) {
throw new Error('Background job could not be started (missing jobId).');
}
const pollMs = opts?.pollMs ?? 1500;
const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']);
while (true) {
const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' });
if (opts?.onProgress) {
opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null);
}
if (job?.status && TERMINAL.has(job.status)) {
if (job.status === 'SUCCESS' && job.result) {
return job.result;
}
throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen');
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
} }
export async function fetchSyncStatus( export async function fetchSyncStatus(
@ -874,6 +914,91 @@ export async function fetchSyncStatus(
}); });
} }
// ============================================================================
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
// ============================================================================
//
// Generic read-only endpoints for the consolidated data tables view.
// All entities are paginated, sortable, filterable via the Unified Filter API
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
export interface TrusteeDataAccount { id: string; [key: string]: any; }
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
export interface TrusteeDataContact { id: string; [key: string]: any; }
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
async function _fetchReadOnlyTable<T = any>(
request: ApiRequestFunction,
instanceId: string,
pathSegment: string,
params?: PaginationParams
): Promise<PaginatedResponse<T> | T[]> {
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
method: 'get',
params: _buildPaginationParams(params),
});
}
export async function fetchDataAccounts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
}
export async function fetchDataJournalEntries(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
}
export async function fetchDataJournalLines(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
}
export async function fetchDataContacts(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
}
export async function fetchDataAccountBalances(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
}
export async function fetchAccountingConfigs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
}
export async function fetchAccountingSyncs(
request: ApiRequestFunction,
instanceId: string,
params?: PaginationParams
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
}
export async function exportAccountingData( export async function exportAccountingData(
request: ApiRequestFunction, request: ApiRequestFunction,
instanceId: string instanceId: string

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -48,6 +50,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>; sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>; filters?: Record<string, any>;
search?: string; search?: string;
viewKey?: string;
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -152,6 +155,7 @@ export async function fetchUsers(
if (params.sort) paginationObj.sort = params.sort; if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters; if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) { if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj); requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Voice / Language Catalog API. * Voice / Language Catalog API.
* *

View file

@ -1,827 +0,0 @@
/**
* Workflow API (GraphicalEditor)
* Node types and graph execution for n8n-style flows.
*/
import type { ApiRequestOptions } from '../hooks/useApi';
const LOG = '[Workflow]';
// ============================================================================
// TYPES
// ============================================================================
export interface NodeTypeParameter {
name: string;
type: string;
required?: boolean;
description?: string;
default?: unknown;
frontendType?: string;
frontendOptions?: Record<string, unknown>;
options?: unknown[];
validation?: Record<string, unknown>;
}
export interface PortField {
name: string;
type: string;
description: Record<string, string>;
required: boolean;
}
export interface PortSchema {
name: string;
fields: PortField[];
}
export interface InputPortDef {
accepts: string[];
}
export interface OutputPortDef {
schema: string;
dynamic?: boolean;
deriveFrom?: string;
}
export interface NodeType {
id: string;
category: string;
label: string;
description: string;
parameters: NodeTypeParameter[];
inputs: number;
outputs: number;
outputLabels?: string[];
executor: string;
inputPorts?: Record<number, InputPortDef>;
outputPorts?: Record<number, OutputPortDef>;
meta?: {
icon?: string;
color?: string;
/** True if this node performs an LLM / AI call (credits). */
usesAi?: boolean;
method?: string;
action?: string;
};
}
export interface NodeTypeCategory {
id: string;
label: Record<string, string> | string;
}
export interface SystemVariable {
type: string;
description: string;
}
export interface NodeTypesResponse {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
}
export interface Automation2GraphNode {
id: string;
type: string;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
}
export interface Automation2Connection {
source: string;
target: string;
sourceOutput?: number;
targetInput?: number;
}
export interface Automation2Graph {
nodes: Automation2GraphNode[];
connections: Automation2Connection[];
}
export interface ExecuteGraphResponse {
success: boolean;
nodeOutputs?: Record<string, unknown>;
error?: string;
stopped?: boolean;
failedNode?: string;
paused?: boolean;
taskId?: string;
runId?: string;
nodeId?: string;
}
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
export interface WorkflowEntryPoint {
id: string;
kind: string;
category: 'on_demand' | 'always_on';
enabled: boolean;
title: Record<string, string> | string;
description?: Record<string, string>;
config: Record<string, unknown>;
}
export interface Automation2Workflow {
id: string;
label: string;
graph: Automation2Graph;
active?: boolean;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
runCount?: number;
/** Enriched: has active (running/paused) run */
isRunning?: boolean;
/** Enriched: status of active run */
runStatus?: string;
/** Enriched: nodeId where workflow is stuck (paused) */
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
}
// ============================================================================
// AUTO-PREFIX TYPES (Greenfield)
// ============================================================================
export type AutoWorkflowStatus = 'draft' | 'published' | 'archived';
export type AutoRunStatus = 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
export type AutoStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
export type AutoTaskStatus = 'pending' | 'completed' | 'cancelled' | 'expired';
export type AutoTemplateScope = 'user' | 'instance' | 'mandate' | 'system';
export interface AutoVersion {
id: string;
workflowId: string;
versionNumber: number;
status: AutoWorkflowStatus;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
publishedAt?: number;
publishedBy?: string;
}
export interface AutoRun {
id: string;
workflowId: string;
versionId?: string;
status: AutoRunStatus;
trigger?: Record<string, unknown>;
startedAt?: number;
completedAt?: number;
nodeOutputs?: Record<string, unknown>;
currentNodeId?: string;
resumeContext?: Record<string, unknown>;
error?: string;
costTokens?: number;
costCredits?: number;
}
export interface AutoWorkflow {
id: string;
mandateId: string;
featureInstanceId: string;
label: string;
description?: string;
tags?: string[];
isTemplate: boolean;
templateSourceId?: string;
templateScope?: AutoTemplateScope;
sharedReadOnly?: boolean;
currentVersionId?: string;
active: boolean;
eventId?: string;
notifyOnFailure?: boolean;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
sysCreatedBy?: string;
sysCreatedAt?: number;
sysModifiedBy?: string;
sysModifiedAt?: number;
}
export interface AutoTask {
id: string;
runId: string;
workflowId: string;
nodeId: string;
nodeType: string;
config: Record<string, unknown>;
assigneeId?: string;
status: AutoTaskStatus;
result?: Record<string, unknown>;
expiresAt?: number;
sysCreatedAt?: number;
}
export interface AutoStepLog {
id: string;
runId: string;
nodeId: string;
nodeType: string;
status: AutoStepStatus;
inputSnapshot?: Record<string, unknown>;
output?: Record<string, unknown>;
error?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
tokensUsed?: number;
retryCount?: number;
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
/**
* Fetch node types for the flow builder (backend-driven).
* GET /api/workflows/{instanceId}/node-types?language=de
*/
export async function fetchNodeTypes(
request: ApiRequestFunction,
instanceId: string,
language = 'de'
): Promise<NodeTypesResponse> {
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
const data = await request({
url: `/api/workflows/${instanceId}/node-types`,
method: 'get',
params: { language },
});
const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? [];
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
return { nodeTypes, categories };
}
/**
* Execute an automation2 graph.
* POST /api/workflows/{instanceId}/execute
*/
export interface ExecuteGraphOptions {
/** Use a configured start on the saved workflow */
entryPointId?: string;
/** Full run envelope (overrides entry point mapping) */
runEnvelope?: Record<string, unknown>;
/** Merged into envelope.payload */
payload?: Record<string, unknown>;
}
export async function executeGraph(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
workflowId?: string,
options?: ExecuteGraphOptions
): Promise<ExecuteGraphResponse> {
console.log(
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
{ nodes: graph.nodes, connections: graph.connections, options }
);
const start = performance.now();
try {
const data: Record<string, unknown> = { graph, workflowId };
if (options?.entryPointId) data.entryPointId = options.entryPointId;
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
const result = await request({
url: `/api/workflows/${instanceId}/execute`,
method: 'post',
data,
});
const ms = Math.round(performance.now() - start);
console.log(
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
result
);
return result;
} catch (err) {
const ms = Math.round(performance.now() - start);
console.error(
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
err
);
throw err;
}
}
// -------------------------------------------------------------------------
// Workflows CRUD
// -------------------------------------------------------------------------
export async function fetchWorkflows(
request: ApiRequestFunction,
instanceId: string,
params?: { active?: boolean; pagination?: any }
): Promise<Automation2Workflow[] | { items: Automation2Workflow[]; pagination: any }> {
const queryParams: Record<string, any> = {};
if (params?.active !== undefined) queryParams.active = params.active;
if (params?.pagination) queryParams.pagination = JSON.stringify(params.pagination);
const data = await request({
url: `/api/workflows/${instanceId}/workflows`,
method: 'get',
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
});
if (data?.items && data?.pagination) return data;
return data?.workflows ?? [];
}
export async function fetchWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
method: 'get',
});
}
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows`,
method: 'post',
data: body,
});
}
export async function updateWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
body: {
label?: string;
graph?: Automation2Graph;
invocations?: WorkflowEntryPoint[];
active?: boolean;
notifyOnFailure?: boolean;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
method: 'put',
data: body,
});
}
export async function deleteWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<void> {
await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
method: 'delete',
});
}
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
export async function deleteSystemWorkflow(
request: ApiRequestFunction,
workflowId: string,
): Promise<void> {
await request({
url: `/api/system/workflow-runs/workflows/${workflowId}`,
method: 'delete',
});
}
export interface Automation2Run {
id: string;
workflowId: string;
status: string;
nodeOutputs?: Record<string, unknown>;
currentNodeId?: string;
}
export async function fetchWorkflowRuns(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<Automation2Run[]> {
const data = await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}/runs`,
method: 'get',
});
return data?.runs ?? [];
}
export interface CompletedRun extends Automation2Run {
workflowLabel?: string;
sysModifiedAt?: number;
sysCreatedAt?: number;
}
export async function fetchCompletedRuns(
request: ApiRequestFunction,
instanceId: string,
limit = 20
): Promise<CompletedRun[]> {
const data = await request({
url: `/api/workflows/${instanceId}/runs/completed`,
method: 'get',
params: { limit },
});
return data?.runs ?? [];
}
// -------------------------------------------------------------------------
// Tasks
// -------------------------------------------------------------------------
export interface Automation2Task {
id: string;
runId: string;
workflowId: string;
nodeId: string;
nodeType: string;
config: Record<string, unknown>;
status: string;
result?: Record<string, unknown>;
/** Workflow label (enriched by API) */
workflowLabel?: string;
/** Unix timestamp ms (from sysCreatedAt) */
createdAt?: number;
/** Optional due date - configurable in future */
dueAt?: number;
}
export async function fetchTasks(
request: ApiRequestFunction,
instanceId: string,
params?: { workflowId?: string; status?: string }
): Promise<Automation2Task[]> {
const data = await request({
url: `/api/workflows/${instanceId}/tasks`,
method: 'get',
params,
});
return data?.tasks ?? [];
}
export async function completeTask(
request: ApiRequestFunction,
instanceId: string,
taskId: string,
result: Record<string, unknown>
): Promise<ExecuteGraphResponse> {
return await request({
url: `/api/workflows/${instanceId}/tasks/${taskId}/complete`,
method: 'post',
data: { result },
});
}
// -------------------------------------------------------------------------
// Versions (AutoVersion Lifecycle)
// -------------------------------------------------------------------------
export async function fetchVersions(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<AutoVersion[]> {
const data = await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions`,
method: 'get',
});
return data?.versions ?? [];
}
export async function createDraftVersion(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<AutoVersion> {
return await request({
url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions/draft`,
method: 'post',
});
}
export async function publishVersion(
request: ApiRequestFunction,
instanceId: string,
versionId: string
): Promise<AutoVersion> {
return await request({
url: `/api/workflows/${instanceId}/versions/${versionId}/publish`,
method: 'post',
});
}
export async function unpublishVersion(
request: ApiRequestFunction,
instanceId: string,
versionId: string
): Promise<AutoVersion> {
return await request({
url: `/api/workflows/${instanceId}/versions/${versionId}/unpublish`,
method: 'post',
});
}
export async function archiveVersion(
request: ApiRequestFunction,
instanceId: string,
versionId: string
): Promise<AutoVersion> {
return await request({
url: `/api/workflows/${instanceId}/versions/${versionId}/archive`,
method: 'post',
});
}
// -------------------------------------------------------------------------
// Templates
// -------------------------------------------------------------------------
export interface AutoWorkflowTemplate extends Automation2Workflow {
isTemplate: boolean;
templateScope?: AutoTemplateScope;
templateSourceId?: string;
sharedReadOnly?: boolean;
}
export async function fetchTemplates(
request: ApiRequestFunction,
instanceId: string,
scope?: AutoTemplateScope,
pagination?: any
): Promise<AutoWorkflowTemplate[] | { items: AutoWorkflowTemplate[]; pagination: any }> {
const queryParams: Record<string, any> = {};
if (scope) queryParams.scope = scope;
if (pagination) queryParams.pagination = JSON.stringify(pagination);
const data = await request({
url: `/api/workflows/${instanceId}/templates`,
method: 'get',
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
});
if (data?.items && data?.pagination) return data;
return data?.templates ?? [];
}
export async function createTemplateFromWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
scope: AutoTemplateScope = 'user'
): Promise<AutoWorkflowTemplate> {
return await request({
url: `/api/workflows/${instanceId}/templates/from-workflow`,
method: 'post',
data: { workflowId, scope },
});
}
export async function copyTemplate(
request: ApiRequestFunction,
instanceId: string,
templateId: string
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/templates/${templateId}/copy`,
method: 'post',
});
}
export async function shareTemplate(
request: ApiRequestFunction,
instanceId: string,
templateId: string,
scope: AutoTemplateScope
): Promise<AutoWorkflowTemplate> {
return await request({
url: `/api/workflows/${instanceId}/templates/${templateId}/share`,
method: 'post',
data: { scope },
});
}
// -------------------------------------------------------------------------
// Connections and Browse (for Email/SharePoint node config)
// -------------------------------------------------------------------------
export interface UserConnection {
id: string;
authority: string;
externalUsername?: string;
externalEmail?: string;
status: string;
}
export async function fetchConnections(
request: ApiRequestFunction,
instanceId: string
): Promise<UserConnection[]> {
const data = await request({
url: `/api/workflows/${instanceId}/connections`,
method: 'get',
});
return data?.connections ?? [];
}
export interface ConnectionService {
service: string;
label: string;
icon: string;
}
export async function fetchConnectionServices(
request: ApiRequestFunction,
instanceId: string,
connectionId: string
): Promise<ConnectionService[]> {
const data = await request({
url: `/api/workflows/${instanceId}/connections/${connectionId}/services`,
method: 'get',
});
return data?.services ?? [];
}
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export async function fetchBrowse(
request: ApiRequestFunction,
instanceId: string,
connectionId: string,
service: string,
path = '/'
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
const data = await request({
url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`,
method: 'get',
params: { service, path },
});
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/teams/${teamId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
// -------------------------------------------------------------------------
// Monitoring / Metrics
// -------------------------------------------------------------------------
export interface WorkflowMetrics {
workflowCount: number;
activeWorkflows: number;
totalRuns: number;
runsByStatus: Record<string, number>;
totalTasks: number;
tasksByStatus: Record<string, number>;
totalTokens: number;
totalCredits: number;
}
export async function fetchMetrics(
request: ApiRequestFunction,
instanceId: string
): Promise<WorkflowMetrics> {
return await request({
url: `/api/workflows/${instanceId}/metrics`,
method: 'get',
});
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessLevelSelect * AccessLevelSelect
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRulesEditor * AccessRulesEditor
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRulesTable * AccessRulesTable
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AccessRules Components * AccessRules Components
* *

View file

@ -0,0 +1,482 @@
/* AddConnectionWizard styles */
.stepper {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
background: var(--bg-secondary, #f0f0f0);
color: var(--text-secondary, #666);
border: 2px solid var(--border-color, #ddd);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.stepDotActive {
background: var(--primary-color, #f25843);
border-color: var(--primary-color, #f25843);
color: white;
}
.stepDotDone {
background: var(--success-color, #22c55e);
border-color: var(--success-color, #22c55e);
color: white;
}
.stepDotHidden {
opacity: 0.3;
}
.body {
padding: 1.5rem;
overflow-y: auto;
}
.stepContent {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 220px;
}
.stepTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.stepBody {
font-size: 0.9375rem;
color: var(--text-primary);
line-height: 1.6;
margin: 0;
}
.stepHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Connector grid (Step 0) */
.connectorGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem;
}
.connectorCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.25rem 1rem;
background: var(--surface-color);
border: 2px solid var(--border-color, #ddd);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
}
.connectorCard:hover {
border-color: var(--primary-color, #f25843);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.connectorIcon {
font-size: 1.75rem;
}
.connectorLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
/* Consent step (Step 1) */
.consentIcon {
display: flex;
justify-content: center;
color: var(--primary-color, #f25843);
}
.consentButtons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.consentButtonYes {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.consentButtonYes:hover {
background: var(--primary-dark, #d94d3a);
}
.consentButtonNo {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--surface-color);
color: var(--text-primary);
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.consentButtonNo:hover {
border-color: var(--text-secondary, #888);
background: var(--bg-secondary, #f5f5f5);
}
/* Preferences step (Step 2) */
.prefGroup {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color, #eee);
}
.prefGroup:last-of-type {
border-bottom: none;
}
.prefLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--text-primary);
cursor: pointer;
font-weight: 500;
}
.prefLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
color: var(--text-primary);
font-weight: 500;
}
.prefIcon {
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.prefCheck {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary-color, #f25843);
}
.prefSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
min-width: 200px;
}
.prefNumber {
width: 80px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
text-align: right;
}
.prefHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Summary step (Step 3) */
.summary {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
overflow: hidden;
}
.summaryRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1rem;
gap: 1rem;
border-bottom: 1px solid var(--border-color, #eee);
}
.summaryRow:last-child {
border-bottom: none;
}
.summaryKey {
font-size: 0.875rem;
color: var(--text-secondary, #666);
font-weight: 500;
}
.summaryVal {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* Back button (step 1 consent screen) */
.stepNavLeft {
margin-top: 0.75rem;
display: flex;
}
.navBack {
background: none;
border: none;
padding: 0.25rem 0;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
cursor: pointer;
text-decoration: underline;
}
.navBack:hover {
color: var(--text-primary);
}
/* Cost estimate hint */
.costHint {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
background: var(--info-bg, #eff6ff);
border: 1px solid var(--info-border, #bfdbfe);
border-radius: 8px;
font-size: 0.8125rem;
}
.costHintIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--info-color, #3b82f6);
}
.costHint > div {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.costHintTitle {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.125rem;
}
.costTable {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
.costLabel {
color: var(--text-secondary, #555);
padding-right: 1rem;
white-space: nowrap;
}
.costVal {
font-weight: 600;
color: var(--info-color, #1d4ed8);
}
.costRowNeut .costLabel,
.costRowNeut .costVal {
padding-top: 0.125rem;
}
.costRowNeut .costVal {
color: #b45309;
}
.costHintWarn {
font-size: 0.75rem;
color: #b45309;
font-weight: 500;
line-height: 1.4;
}
.costHintNote {
color: var(--text-secondary, #555);
font-size: 0.75rem;
}
:global(.dark-theme) .costHint {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.3);
}
:global(.dark-theme) .costVal {
color: #93c5fd;
}
:global(.dark-theme) .costRowNeut .costVal,
:global(.dark-theme) .costHintWarn {
color: #fbbf24;
}
/* Navigation */
.stepNav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 0.5rem;
gap: 0.75rem;
}
.navBack {
padding: 0.5rem 1rem;
background: var(--surface-color);
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navBack:hover {
background: var(--bg-secondary, #f5f5f5);
}
.navNext {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navNext:hover {
background: var(--primary-dark, #d94d3a);
}
.navConnect {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.5rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.navConnect:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.navConnect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.patInput {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9rem;
font-family: monospace;
margin: 12px 0 16px;
}
.patInput:focus {
outline: none;
border-color: var(--primary, #2563eb);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);
}
:global(.dark-theme) .prefSelect,
:global(.dark-theme) .prefNumber {
background: var(--surface-color);
color: var(--text-primary);
}
:global(.dark-theme) .summary {
border-color: var(--border-color);
}
:global(.dark-theme) .summaryRow {
border-color: var(--border-color);
}

View file

@ -0,0 +1,295 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* AddConnectionWizard
*
* Streamlined multi-step modal for adding a new connector.
* Steps are connector-type-aware:
* Base: Connector Consent Connect
* Microsoft: Connector Consent Admin Consent (optional) Connect
* Infomaniak: Connector Consent PAT Input (done)
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState {
currentStep: StepId;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
infomaniakToken: string;
adminConsentDone: boolean;
}
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
infomaniak: 'Infomaniak',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
};
function _getSteps(connector: ConnectorType | null): StepId[] {
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
return ['connector', 'consent', 'connect'];
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
onMsftAdminConsent?: () => void;
isConnecting?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false,
}) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({
currentStep: 'connector',
connector: null,
knowledgeEnabled: false,
infomaniakToken: '',
adminConsentDone: false,
});
const reset = () =>
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
const handleClose = () => { reset(); onClose(); };
const steps = _getSteps(state.connector);
const stepIndex = steps.indexOf(state.currentStep);
const goNext = () => {
const nextIdx = stepIndex + 1;
if (nextIdx < steps.length) {
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
}
};
const goBack = () => {
const prevIdx = stepIndex - 1;
if (prevIdx >= 0) {
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
}
};
const selectConnector = (c: ConnectorType) => {
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
};
const setConsent = (enabled: boolean) => {
setState(s => ({ ...s, knowledgeEnabled: enabled }));
goNext();
};
const handleFinalConnect = async () => {
if (!state.connector) return;
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
} else {
await onConnect(state.connector, state.knowledgeEnabled);
}
reset();
onClose();
};
return (
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
{/* Stepper */}
<div className={styles.stepper}>
{steps.map((s, i) => (
<div
key={s}
className={[
styles.stepDot,
stepIndex === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '',
].join(' ')}
>
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step: Connector ---- */}
{state.currentStep === 'connector' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => selectConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* ---- Step: Consent ---- */}
{state.currentStep === 'consent' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
<p className={styles.stepBody}>
{t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
</p>
<p className={styles.stepHint}>
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
</p>
<div className={styles.consentButtons}>
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
<FaCheck /> {t('Ja, aktivieren')}
</button>
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
{t('Nein, überspringen')}
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step: MSFT Admin Consent ---- */}
{state.currentStep === 'msftAdminConsent' && (
<div className={styles.stepContent}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
</div>
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
<p className={styles.stepBody}>
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
</p>
<p className={styles.stepHint}>
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
>
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
</button>
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
{t('Überspringen')}
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
</div>
</div>
)}
{/* ---- Step: Infomaniak PAT ---- */}
{state.currentStep === 'infomaniakPat' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
<p className={styles.stepBody}>
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
</p>
<input
type="password"
placeholder="pat_..."
value={state.infomaniakToken}
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
className={styles.patInput}
autoFocus
/>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting || !state.infomaniakToken.trim()}
>
{isConnecting ? t('Verbinden…') : t('Verbinden')}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
{/* ---- Step: Connect ---- */}
{state.currentStep === 'connect' && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Anbieter')}</span>
<span className={styles.summaryVal}>
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')}
</span>
</div>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
<button
type="button"
className={styles.navConnect}
onClick={handleFinalConnect}
disabled={isConnecting}
>
{isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AddConnectionWizard;

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ChatInput -- Shared chat input component. * ChatInput -- Shared chat input component.
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ChatMessageList -- Shared chat message display component. * ChatMessageList -- Shared chat message display component.
* *

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ChatMessageList } from './ChatMessageList'; export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList'; export type { ChatMessage } from './ChatMessageList';
export { ChatInput } from './ChatInput'; export { ChatInput } from './ChatInput';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { IoIosDownload, IoIosCopy } from 'react-icons/io'; import { IoIosDownload, IoIosCopy } from 'react-icons/io';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { IoIosDownload } from 'react-icons/io'; import { IoIosDownload } from 'react-icons/io';
import { Popup, PopupAction } from '../UiComponents/Popup/Popup'; import { Popup, PopupAction } from '../UiComponents/Popup/Popup';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ContentPreview } from './ContentPreview'; export { ContentPreview } from './ContentPreview';
export type { ContentPreviewProps } from './ContentPreview'; export type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview'; export { UrlContentPreview } from './UrlContentPreview';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface ApplicationRendererProps { interface ApplicationRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface HtmlRendererProps { interface HtmlRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
interface ImageRendererProps { interface ImageRendererProps {

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState } from 'react'; import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
// @ts-ignore // @ts-ignore
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { IoIosWarning } from 'react-icons/io'; import { IoIosWarning } from 'react-icons/io';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { renderAsync } from 'docx-preview'; import { renderAsync } from 'docx-preview';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { JsonRenderer } from './JsonRenderer'; export { JsonRenderer } from './JsonRenderer';
export { ImageRenderer } from './ImageRenderer'; export { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer'; export { TextRenderer } from './TextRenderer';

View file

@ -1,75 +0,0 @@
/**
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue {
currentNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
portTypeCatalog: Record<string, PortSchema>;
systemVariables: Record<string, SystemVariable>;
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[];
}
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
return useContext(Automation2DataFlowContext);
}
interface Automation2DataFlowProviderProps {
node: CanvasNode | null;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
children: React.ReactNode;
}
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
node,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
portTypeCatalog = {},
systemVariables = {},
children,
}) => {
const value = useMemo((): Automation2DataFlowContextValue | null => {
if (!node) return null;
return {
currentNodeId: node.id,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
portTypeCatalog,
systemVariables,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
};
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
return (
<Automation2DataFlowContext.Provider value={value}>
{children}
</Automation2DataFlowContext.Provider>
);
};

View file

@ -0,0 +1,144 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Workflow Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
*/
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowAutomationApi';
export interface WorkflowDataFlowContextValue {
currentNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
portTypeCatalog: Record<string, PortSchema>;
systemVariables: Record<string, SystemVariable>;
/** Canonical form field types from the API — maps UI type id to portType primitive. */
formFieldTypes: FormFieldType[];
/** Backend-driven condition operators per valueKind (flow.ifElse). */
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
getAvailableSourceIds: () => string[];
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
instanceId?: string;
request?: ApiRequestFunction;
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
}
const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null);
export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null {
return useContext(WorkflowDataFlowContext);
}
interface WorkflowDataFlowProviderProps {
node: CanvasNode | null;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeOutputsPreview: Record<string, unknown>;
nodeTypes: NodeType[];
language: string;
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
}
export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({
node,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
conditionOperatorCatalog = {},
instanceId,
request,
children,
}) => {
const value = useMemo((): WorkflowDataFlowContextValue | null => {
if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType])
);
const resolvePortType = (rawType: string): string => formTypeToPort[rawType] ?? rawType;
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
const raw = node.parameters?.[parameterKey];
if (!Array.isArray(raw)) return null;
const fields: PortField[] = [];
for (const item of raw) {
if (typeof item !== 'object' || item === null) continue;
const rec = item as Record<string, unknown>;
if (typeof rec.name !== 'string') continue;
const lab = rec.label;
const desc =
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
if (rawType === 'group' && Array.isArray(rec.fields)) {
for (const sub of rec.fields as Record<string, unknown>[]) {
if (!sub || typeof sub.name !== 'string') continue;
const sl = sub.label;
const sdesc =
typeof sl === 'string'
? sl
: typeof sl === 'object' && sl !== null
? String((sl as Record<string, string>).de ?? '')
: '';
fields.push({
name: `${rec.name}.${sub.name}`,
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: resolvePortType(rawType),
description: (desc && desc.trim()) || rec.name,
required: Boolean(rec.required),
});
}
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
};
return {
currentNodeId: node.id,
nodes,
connections,
nodeOutputsPreview,
nodeTypes,
language,
portTypeCatalog,
systemVariables,
formFieldTypes,
conditionOperatorCatalog,
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
n.title ?? n.label ?? n.type ?? n.id,
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
instanceId,
request,
parseGraphDefinedSchema,
};
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return (
<WorkflowDataFlowContext.Provider value={value}>
{children}
</WorkflowDataFlowContext.Provider>
);
};

View file

@ -1,26 +1,83 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result. * CanvasHeader - Workflow controls, version selector, and execute result.
*/ */
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa'; import {
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi'; FaPlay,
import styles from './Automation2FlowEditor.module.css'; FaSpinner,
FaCloudUploadAlt,
FaCloudDownloadAlt,
FaArchive,
FaBookmark,
FaCaretDown,
FaSave,
FaPlus,
FaChevronLeft,
FaChevronRight,
} from 'react-icons/fa';
import {
HiOutlineMagnifyingGlassMinus,
HiOutlineMagnifyingGlassPlus,
HiOutlineArrowUturnLeft,
HiOutlineArrowUturnRight,
HiOutlineTrash,
HiOutlineDocumentDuplicate,
HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2,
} from 'react-icons/hi2';
import type { WorkflowDefinition, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowAutomationApi';
import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
import { Button } from '../../UiComponents/Button';
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
export interface CanvasHeaderCanvasEditProps {
zoomPercent: number;
selectedNodeCount: number;
connectionSelected: boolean;
stickyNoteSelected: boolean;
connectionToolActive: boolean;
canUndo: boolean;
canRedo: boolean;
onZoomIn: () => void;
onZoomOut: () => void;
onZoomPercentCommit: (percent: number) => void;
onFitWindow: () => void;
onResetView: () => void;
onUndo: () => void;
onRedo: () => void;
onDeleteSelection: () => void;
onDuplicateNode: () => void;
onToggleConnectionTool: () => void;
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
onAddCanvasComment: () => void;
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
onArrangeNodes: () => void;
}
interface CanvasHeaderProps { interface CanvasHeaderProps {
workflows: Automation2Workflow[]; workflows: WorkflowDefinition[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void; onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;
onSave: () => void; onSave: () => void;
onExecute: () => void; onExecute: () => void;
onWorkflowSettings?: () => void; onToggleWorkspacePanel?: () => void;
onToggleChat?: () => void; workspacePanelOpen?: boolean;
saving: boolean; saving: boolean;
executing: boolean; executing: boolean;
hasNodes: boolean; hasNodes: boolean;
/** When set, required-field graph errors block a normal run; message is the
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
* the first offending node. */
executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null; executeResult: ExecuteGraphResponse | null;
versions?: AutoVersion[]; versions?: AutoVersion[];
currentVersionId?: string | null; currentVersionId?: string | null;
@ -33,8 +90,11 @@ interface CanvasHeaderProps {
onSaveAsTemplate?: (scope: AutoTemplateScope) => void; onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean; templateSaving?: boolean;
onNewFromTemplate?: () => void; onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void; /** Sysadmin-only: when true, NodeConfigPanel renders the static
onAutoLayout?: () => void; * "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
canvasEdit?: CanvasHeaderCanvasEditProps;
} }
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> { function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -45,17 +105,23 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
}; };
} }
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows, const _tb = 'secondary' as const;
const _ts = 'sm' as const;
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId, currentWorkflowId,
onWorkflowSelect, onWorkflowSelect,
onNew, onNew,
onSave, onSave,
onExecute, onExecute,
onWorkflowSettings, onToggleWorkspacePanel,
onToggleChat, workspacePanelOpen,
saving, saving,
executing, executing,
hasNodes, hasNodes,
executeBlockedReason,
onExecuteBlockedClick,
executeResult, executeResult,
versions, versions,
currentVersionId, currentVersionId,
@ -68,10 +134,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onSaveAsTemplate, onSaveAsTemplate,
templateSaving, templateSaving,
onNewFromTemplate, onNewFromTemplate,
onWorkflowRename, verboseSchema,
onAutoLayout, onVerboseSchemaChange,
canvasEdit,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
const statusBadge = _getStatusBadge(t); const statusBadge = _getStatusBadge(t);
const currentVersion = versions?.find((v) => v.id === currentVersionId); const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft'; const currentStatus = currentVersion?.status || 'draft';
@ -83,38 +151,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
const [templateMenuOpen, setTemplateMenuOpen] = useState(false); const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null); const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false); const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const [nameValue, setNameValue] = useState(''); const zoomMenuRef = useRef<HTMLDivElement>(null);
const nameInputRef = useRef<HTMLInputElement>(null); const [zoomInputDraft, setZoomInputDraft] = useState('');
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
useEffect(() => { useEffect(() => {
if (editingName && nameInputRef.current) { const zp = canvasEdit?.zoomPercent;
nameInputRef.current.focus(); if (zp !== undefined) setZoomInputDraft(String(zp));
nameInputRef.current.select(); }, [canvasEdit?.zoomPercent]);
}
}, [editingName]);
useEffect(() => { useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => { const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false); if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false); if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
}; };
document.addEventListener('mousedown', _handleClickOutside); document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside); return () => document.removeEventListener('mousedown', _handleClickOutside);
@ -130,185 +180,376 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t] [t]
); );
return ( const _panelOpen = workspacePanelOpen ?? false;
<div className={styles.canvasHeader}> const _runAriaLabel = executing
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}> ? t('Ausführen…')
{/* Workflow name: inline editable */} : executeBlockedReason
{currentWorkflowId && currentWorkflow ? ( ? t('Pflicht-Felder fehlen')
editingName ? ( : t('Ausführen');
<input const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
ref={nameInputRef}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
/>
) : (
<h4
className={styles.canvasTitle}
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
onClick={_startNameEdit}
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
{t('Neuer Workflow')}
</h4>
)}
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
{/* Split "Neu" button */} const _executeBannerSegmentClass = !executeResult
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}> ? ''
<div style={{ display: 'flex' }}> : executeResult.success
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}> ? executeResult.warning
{t('Neu')} ? styles.canvasHeaderExecuteBannerWarning
</button> : styles.canvasHeaderExecuteBannerSuccess
<button : executeResult.paused
? styles.canvasHeaderExecuteBannerPaused
: styles.canvasHeaderExecuteBannerError;
const _commitZoomDraft = () => {
if (!canvasEdit) return;
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
const n = parseFloat(raw);
if (!Number.isFinite(n)) {
setZoomInputDraft(String(canvasEdit.zoomPercent));
return;
}
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
setZoomMenuOpen(false);
};
const _canDeleteSelection =
!!canvasEdit &&
(canvasEdit.selectedNodeCount > 0 ||
canvasEdit.connectionSelected ||
canvasEdit.stickyNoteSelected);
const _singleNodeOnly =
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
return (
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
<div
className={styles.canvasHeaderToolbar}
role="toolbar"
aria-label={t('Workflow-Aktionen')}
>
{onToggleWorkspacePanel && (
<Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
onClick={() => setNewMenuOpen((p) => !p)} size={_ts}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }} icon={_panelOpen ? FaChevronLeft : FaChevronRight}
title={t('Neu aus Vorlage')} className={styles.canvasHeaderIconBtn}
> onClick={onToggleWorkspacePanel}
<FaCaretDown style={{ fontSize: '0.7rem' }} /> title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
</button> aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
</div> />
{newMenuOpen && ( )}
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}> <div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<button <div className={styles.canvasHeaderSplitPair}>
<Button
type="button" type="button"
onClick={() => { onNew(); setNewMenuOpen(false); }} variant={_tb}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }} size={_ts}
> icon={FaPlus}
{t('Leerer Workflow')} className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
</button> onClick={onNew}
title={t('Neuer leerer Workflow')}
aria-label={t('Neuer leerer Workflow')}
/>
{onNewFromTemplate && ( {onNewFromTemplate && (
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaCaretDown}
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
title={t('Aus Vorlage…')}
aria-label={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
/>
)}
</div>
{newMenuOpen && onNewFromTemplate && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button <button
type="button" type="button"
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }} className={styles.canvasHeaderMenuItem}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }} onClick={() => {
onNewFromTemplate();
setNewMenuOpen(false);
}}
role="menuitem"
> >
{t('Aus Vorlage…')} {t('Aus Vorlage…')}
</button> </button>
</div>
)}
</div>
<select
className={styles.canvasHeaderWorkflowSelect}
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
aria-label={t('Workflow laden')}
title={t('Workflow laden')}
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<Button
type="button"
variant={_tb}
size={_ts}
icon={saving ? undefined : FaSave}
className={styles.canvasHeaderIconBtn}
loading={saving}
disabled={saving}
onClick={onSave}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
aria-label={t('Speichern')}
/>
<Button
type="button"
variant={_tb}
size={_ts}
icon={executing ? undefined : FaPlay}
loading={executing}
disabled={executing || !hasNodes}
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
aria-label={_runAriaLabel}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={_runTitle}
/>
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<Button
type="button"
variant={_tb}
size={_ts}
icon={FaBookmark}
loading={templateSaving}
disabled={templateSaving}
onClick={() => setTemplateMenuOpen((p) => !p)}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{t('Als Vorlage')}
</Button>
{templateMenuOpen && (
<div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => (
<button
key={s}
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => {
onSaveAsTemplate(s);
setTemplateMenuOpen(false);
}}
role="menuitem"
>
{scopeLabels[s]}
</button>
))}
</div>
)} )}
</div> </div>
)} )}
</div>
<button {_isSysAdmin && onVerboseSchemaChange && (
type="button" <label
className={styles.retryButton} className={styles.canvasHeaderSysadmin}
onClick={onSave} title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
disabled={saving || !hasNodes} >
<input
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
className={styles.canvasHeaderSysadminInput}
/>
{t('Schema-Details')}
</label>
)}
</div>
{canvasEdit && (
<div
className={styles.canvasHeaderEditRow}
role="toolbar"
aria-label={t('Canvas bearbeiten')}
> >
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')} <div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
</button> <div className={styles.canvasHeaderZoomInputWrap}>
<input
{onAutoLayout && ( type="text"
<button inputMode="numeric"
type="button" className={styles.canvasHeaderZoomInput}
className={styles.retryButton} value={zoomInputDraft}
onClick={onAutoLayout} onChange={(e) => setZoomInputDraft(e.target.value)}
disabled={!hasNodes} onBlur={_commitZoomDraft}
title={t('Knoten automatisch anordnen')} onKeyDown={(e) => {
> if (e.key === 'Enter') {
<FaSitemap style={{ marginRight: '0.4rem' }} /> e.preventDefault();
{t('Anordnen')} _commitZoomDraft();
</button> }
)} }}
aria-label={t('Zoomstufe (Prozent)')}
{/* Save as template */} title={t('Zoomstufe (Prozent)')}
{currentWorkflowId && onSaveAsTemplate && ( />
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}> <span className={styles.canvasHeaderZoomSuffix} aria-hidden>
%
</span>
</div>
<button <button
type="button" type="button"
className={styles.retryButton} className={styles.canvasHeaderZoomChevronBtn}
onClick={() => setTemplateMenuOpen((p) => !p)} onClick={() => setZoomMenuOpen((p) => !p)}
disabled={templateSaving} aria-label={t('Zoom-Voreinstellungen')}
title={t('Als Vorlage speichern')} aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
title={t('Zoom-Voreinstellungen')}
> >
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>} <FaCaretDown aria-hidden />
</button> </button>
{templateMenuOpen && ( {zoomMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}> <div className={styles.canvasHeaderMenuDropdown} role="menu">
{(['user', 'instance', 'mandate'] as const).map((s) => ( <button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onFitWindow();
setZoomMenuOpen(false);
}}
>
{t('Ansicht an Fenster anpassen')}
</button>
<button
type="button"
className={styles.canvasHeaderMenuItem}
role="menuitem"
onClick={() => {
canvasEdit.onResetView();
setZoomMenuOpen(false);
}}
>
{t('Ansicht zurücksetzen')}
</button>
{ZOOM_PRESET_PERCENTS.map((pct) => (
<button <button
key={s} key={pct}
type="button" type="button"
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }} className={styles.canvasHeaderMenuItem}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }} role="menuitem"
onClick={() => {
canvasEdit.onZoomPercentCommit(pct);
setZoomMenuOpen(false);
}}
> >
{scopeLabels[s]} {pct}%
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </div>
)} <button
<select type="button"
value={currentWorkflowId ?? ''} className={styles.canvasHeaderGhostIconBtn}
onChange={(e) => { onClick={canvasEdit.onZoomIn}
const id = e.target.value ? e.target.value : null; title={t('Vergrößern')}
onWorkflowSelect(id); aria-label={t('Vergrößern')}
}} >
style={{ padding: '0.4rem', minWidth: 180 }} <HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
disabled={executing || !hasNodes}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
{t('Ausführen…')}
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
{t('Ausführen')}
</>
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
{t('Workspace')}
</button> </button>
)} <button
</div> type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onZoomOut}
title={t('Verkleinern')}
aria-label={t('Verkleinern')}
>
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canUndo}
onClick={canvasEdit.onUndo}
title={t('Rückgängig')}
aria-label={t('Rückgängig')}
>
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!canvasEdit.canRedo}
onClick={canvasEdit.onRedo}
title={t('Wiederholen')}
aria-label={t('Wiederholen')}
>
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_canDeleteSelection}
onClick={canvasEdit.onDeleteSelection}
title={t('Auswahl löschen')}
aria-label={t('Auswahl löschen')}
>
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!_singleNodeOnly}
onClick={canvasEdit.onDuplicateNode}
title={t('Knoten duplizieren')}
aria-label={t('Knoten duplizieren')}
>
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
disabled={!hasNodes}
onClick={canvasEdit.onArrangeNodes}
title={t('Knoten im Raster anordnen')}
aria-label={t('Knoten im Raster anordnen')}
>
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.canvasHeaderGhostIconBtn}
onClick={canvasEdit.onAddCanvasComment}
title={t('Kommentar auf dem Canvas einfügen')}
aria-label={t('Kommentar auf dem Canvas einfügen')}
>
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
</button>
</div>
)}
{/* Version Selector */}
{currentWorkflowId && versions && versions.length > 0 && ( {currentWorkflowId && versions && versions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}> <div className={styles.canvasHeaderVersionRow}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span> <span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
<select <select
className={styles.canvasHeaderVersionSelect}
value={currentVersionId ?? ''} value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)} onChange={(e) => onVersionSelect?.(e.target.value || null)}
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading} disabled={versionLoading}
aria-label={t('Version')}
> >
<option value="">{t('Aktuelle')}</option> <option value="">{t('Aktuelle')}</option>
{versions.map((v) => ( {versions.map((v) => (
@ -318,100 +559,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
))} ))}
</select> </select>
<span <span
style={{ className={styles.canvasHeaderVersionBadge}
padding: '2px 8px', style={
borderRadius: 10, {
fontSize: '0.75rem', '--canvasHeaderBadgeBg': `${badge.color}22`,
fontWeight: 600, '--canvasHeaderBadgeFg': badge.color,
background: badge.color + '22', } as React.CSSProperties
color: badge.color, }
}}
> >
{badge.label} {badge.label}
</span> </span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && ( {currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaCloudUploadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onPublishVersion(currentVersion.id)} onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Version veröffentlichen')} title={t('Version veröffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaCloudUploadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichen')} {t('Veröffentlichen')}
</button> </Button>
)} )}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && ( {currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaCloudDownloadAlt}
className={styles.canvasHeaderVersionAction}
onClick={() => onUnpublishVersion(currentVersion.id)} onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')} title={t('Veröffentlichung zurücknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichung aufheben')} {t('Veröffentlichung aufheben')}
</button> </Button>
)} )}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && ( {currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaArchive}
className={styles.canvasHeaderVersionAction}
onClick={() => onArchiveVersion(currentVersion.id)} onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading} disabled={versionLoading}
title={t('Version archivieren')} title={t('Version archivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
<FaArchive style={{ marginRight: 4 }} /> {t('Archiv')}
Archiv </Button>
</button>
)} )}
{onCreateDraft && ( {onCreateDraft && (
<button <Button
type="button" type="button"
className={styles.retryButton} variant={_tb}
size={_ts}
icon={FaPlus}
className={styles.canvasHeaderVersionAction}
onClick={onCreateDraft} onClick={onCreateDraft}
disabled={versionLoading} disabled={versionLoading}
title={t('Neuen Entwurf erstellen')} title={t('Neuen Entwurf erstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
> >
+ Entwurf {t('+ Entwurf')}
</button> </Button>
)} )}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />} {versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
</div> </div>
)} )}
{executeResult && ( {executeResult && (
<div <div
style={{ className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
> >
{executeResult.success ? ( {executeResult.success ? (
<>{t('Ausführung abgeschlossen')}</> executeResult.warning ? (
) : (executeResult as { paused?: boolean }).paused ? ( <>{executeResult.warning}</>
) : (
<>{t('Ausführung abgeschlossen')}</>
)
) : executeResult.paused ? (
<> <>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den {t('Workflow pausiert. Öffne ')}
Task zu bearbeiten. <strong>{t('Workflows/Tasks')}</strong>
{t(' in der Sidebar, um den Task zu bearbeiten.')}
</> </>
) : ( ) : (
<> {executeResult.error ?? t('Unbekannter Fehler')}</> <>{executeResult.error ?? t('Unbekannter Fehler')}</>
)} )}
</div> </div>
)} )}

View file

@ -1,10 +1,12 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorChatPanel * EditorChatPanel
* *
* AI Chat sidebar for the GraphicalEditor. * AI Chat sidebar for the WorkflowAutomation editor.
* Streams responses via SSE (same pattern as Workspace chat). * Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput: * File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FolderTree onto input area, or click in UDB * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources) * - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/ */
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
@ -32,7 +34,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile { export interface PendingFile {
fileId: string; fileId: string;
fileName: string; fileName: string;
itemType?: 'file' | 'folder'; itemType?: 'file' | 'group';
} }
export interface EditorDataSource { export interface EditorDataSource {
@ -87,7 +89,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
// Load persisted chat history from the backend whenever the workflow changes. // Load persisted chat history from the backend whenever the workflow changes.
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. // returned by `GET /api/workflow-automation/{workflowId}/chat/messages`.
// For an unsaved workflow (workflowId == null) we just clear the panel. // For an unsaved workflow (workflowId == null) we just clear the panel.
useEffect(() => { useEffect(() => {
if (!workflowId) { if (!workflowId) {
@ -99,7 +101,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
setHistoryLoading(true); setHistoryLoading(true);
try { try {
const res = await api.get<PersistedEditorChatResponse>( const res = await api.get<PersistedEditorChatResponse>(
`/api/workflows/${instanceId}/${workflowId}/chat/messages`, `/api/workflow-automation/${workflowId}/chat/messages`,
); );
if (cancelled) return; if (cancelled) return;
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
@ -166,7 +168,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const baseURL = api.defaults.baseURL || ''; const baseURL = api.defaults.baseURL || '';
const cleanup = startSseStream({ const cleanup = startSseStream({
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
body, body,
handlers: { handlers: {
onChunk: (event) => { onChunk: (event) => {
@ -227,7 +229,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
: m)); : m));
} }
try { try {
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
} catch { } catch {
} }
abortRef.current?.(); abortRef.current?.();
@ -241,7 +243,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}, [_handleSend]); }, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => { const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) { if (
e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/group-id') ||
e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids')
) {
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true); setTreeDropOver(true);
@ -252,6 +259,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const _handleDrop = useCallback((e: React.DragEvent) => { const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false); setTreeDropOver(false);
const groupId = e.dataTransfer.getData('application/group-id');
if (groupId) {
e.preventDefault();
e.stopPropagation();
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) { if (treeItemsJson) {
e.preventDefault(); e.preventDefault();
@ -282,11 +295,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<span key={pf.fileId} style={{ <span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4, display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11, padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0', background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100', color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`, fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
}}> }}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName} {pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && ( {onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{ <button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1, border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,

View file

@ -1,17 +1,19 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorWorkflowChatList * EditorWorkflowChatList
* *
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated * UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
* as one editor chat session. Lists workflows already loaded by the parent * is treated as one editor chat session. Lists workflows already loaded by the
* editor (no extra fetch), supports search and "+ Neu" to start a fresh * parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses * workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
* GraphicalEditor data instead of the workspace endpoint. * WorkflowAutomation data instead of the workspace endpoint.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import type { Automation2Workflow } from '../../../api/workflowApi'; import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
interface EditorWorkflowChatListProps { interface EditorWorkflowChatListProps {
workflows: Automation2Workflow[]; workflows: WorkflowDefinition[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void; onSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;
@ -48,7 +50,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
const list = q const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q)) ? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows]; : [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0)); list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
return list; return list;
}, [workflows, search]); }, [workflows, search]);
@ -85,7 +87,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
) : ( ) : (
filtered.map((wf) => { filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId; const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt; const ts = wf.lastStartedAt || wf.sysCreatedAt;
return ( return (
<div <div
key={wf.id} key={wf.id}

File diff suppressed because it is too large Load diff

View file

@ -1,17 +1,99 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeConfigPanel - Generic parameter renderer for all node types. * NodeConfigPanel - Generic parameter renderer for all node types.
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType. * Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi'; import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
import type { ApiRequestFunction } from '../../../api/workflowApi'; import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import styles from './Automation2FlowEditor.module.css'; import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList';
import type { AccordionListItem } from '../../UiComponents/AccordionList';
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
const raw = stored[param.name];
if (param.required) {
return raw !== undefined && raw !== null ? raw : param.default;
}
return raw;
}
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
const raw = currentParams[name];
const s = raw !== undefined && raw !== null ? String(raw) : '';
if (s !== '') return s;
const meta = nt.parameters?.find((p) => p.name === name);
const d = meta?.default;
return d !== undefined && d !== null ? String(d) : '';
}
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
return (
<span style={{ fontWeight: 700, fontSize: 12 }}>
{param.required ? (
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
*
</span>
) : null}
{param.name}
</span>
);
}
function verboseSchemaTypeBadge(
verboseSchema: boolean,
param: NodeTypeParameter,
t: (key: string) => string,
): React.ReactElement | null {
if (!verboseSchema || !param.type) return null;
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 6,
flexWrap: 'wrap',
minWidth: 0,
}}
>
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
</div>
);
}
interface NodeConfigPanelProps { interface NodeConfigPanelProps {
node: CanvasNode | null; node: CanvasNode | null;
@ -22,6 +104,38 @@ interface NodeConfigPanelProps {
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void; onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
instanceId?: string; instanceId?: string;
request?: ApiRequestFunction; request?: ApiRequestFunction;
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
verboseSchema?: boolean;
}
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
* parameter unless the referenced parameter's effective value matches.
*/
export function parameterVisibleForFrontendOptions(
param: NodeTypeParameter,
params: Record<string, unknown>,
nodeType: NodeType,
): boolean {
const fo = param.frontendOptions;
if (!fo || typeof fo !== 'object') return true;
const dependsOnRaw = fo.dependsOn as unknown;
const showWhenRaw = fo.showWhen as unknown;
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
return true;
}
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
const rawSibling = params[dependsOnRaw];
const siblingValue =
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
const fallback =
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
const effective = siblingValue !== '' ? siblingValue : fallback;
const allowed: string[] = Array.isArray(showWhenRaw)
? showWhenRaw.map((x) => String(x))
: [String(showWhenRaw)];
return allowed.includes(effective);
} }
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node, export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
@ -32,6 +146,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
onNodeUpdate, onNodeUpdate,
instanceId, instanceId,
request, request,
verboseSchema = false,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
@ -55,7 +170,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
const updateParam = useCallback( const updateParam = useCallback(
(key: string, value: unknown) => { (key: string, value: unknown) => {
setParams((prev) => { setParams((prev) => {
const next = { ...prev, [key]: value }; const next = { ...prev };
if (value === undefined) {
delete next[key];
} else {
next[key] = value;
}
const id = nodeIdRef.current; const id = nodeIdRef.current;
if (id) { if (id) {
if (notifyParentTimeoutRef.current != null) { if (notifyParentTimeoutRef.current != null) {
@ -72,11 +192,216 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange] [onParametersChange]
); );
const patchParams = useCallback(
(patch: Record<string, unknown>) => {
setParams((prev) => {
const next = { ...prev, ...patch };
const id = nodeIdRef.current;
if (id) {
if (notifyParentTimeoutRef.current != null) {
clearTimeout(notifyParentTimeoutRef.current);
}
notifyParentTimeoutRef.current = setTimeout(() => {
notifyParentTimeoutRef.current = null;
onParametersChange(id, next);
}, 0);
}
return next;
});
},
[onParametersChange]
);
const dataFlow = useWorkflowDataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
// nicht nach unten scrollen muss, um zu sehen was fehlt.
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
const all = nodeType?.parameters ?? [];
const required = all.filter((p) => p.required);
const optional = all.filter((p) => !p.required);
return [...required, ...optional];
}, [nodeType?.parameters]);
// Pre-compute which required params are unbound on this node so we can
// surface a panel-level summary banner. The hidden-param safety net lives
// inside `findRequiredErrors` so banner, canvas badges and Run-button stay
// in lockstep.
// Banner labels are kept short (`param.name`); the full description is
// attached as the tooltip below.
const requiredErrors = useMemo(() => {
if (!node || !nodeType) return [];
return findRequiredErrors(node, nodeType, (p) => p.name);
}, [node, nodeType]);
// Resolve full descriptions per missing param (for the banner tooltip).
const requiredErrorTooltip = useMemo(() => {
if (!requiredErrors.length || !nodeType) return '';
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
return requiredErrors
.map((e) => {
const p = byName.get(e.paramName);
const desc = p ? (getLabel(p.description, language) || '') : '';
return desc ? `${e.paramName}: ${desc}` : e.paramName;
})
.join('\n');
}, [requiredErrors, nodeType, language]);
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
const out: AccordionListItem<string>[] = [];
for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
const usePicker = _shouldUseRequiredPicker(param);
if (usePicker) {
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
),
});
continue;
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
if (param.name === 'outputMode') {
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
{chunksNested ? (
<div style={{ marginTop: 8 }}>
<AccordionList<string>
key={`extract-chunks-${node.id}`}
defaultOpenId={null}
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
const cp = byName.get(chunkName);
if (!cp) {
return { id: chunkName, title: chunkName, children: <></> };
}
const ft = cp.frontendType || 'text';
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
return {
id: chunkName,
title: accordionExtractParamTitle(cp, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
<ChunkRenderer
param={cp}
value={workflowParamUiValue(params, cp)}
onChange={(val: unknown) => updateParam(cp.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
};
})}
/>
</div>
) : null}
</div>
),
});
continue;
}
out.push({
id: param.name,
title: accordionExtractParamTitle(param, t),
children: (
<div style={{ minWidth: 0 }}>
{verboseSchemaTypeBadge(verboseSchema, param, t)}
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
hideAccordionTitle
/>
</div>
),
});
}
return out;
}, [
sortedParameters,
params,
nodeType,
language,
node?.id,
node?.type,
verboseSchema,
instanceId,
request,
patchParams,
updateParam,
t,
]);
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
if (!param) return null;
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
return param;
}, [node, nodeType, sortedParameters, params]);
if (!node || !nodeType) return null; if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
const showNameField = onNodeUpdate && !isTrigger; const showNameField = onNodeUpdate && !isTrigger;
const parameters = nodeType.parameters || []; const parameters = sortedParameters;
const inputPortDefs = nodeType.inputPorts ?? {};
const outputPortDefs = nodeType.outputPorts ?? {};
const inputPortEntries = Object.entries(inputPortDefs);
const outputPortEntries = Object.entries(outputPortDefs);
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
return ( return (
<div className={styles.nodeConfigPanel}> <div className={styles.nodeConfigPanel}>
@ -101,20 +426,316 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
{getLabel(nodeType.description, language)} {getLabel(nodeType.description, language)}
</p> </p>
)} )}
{parameters.map((param: NodeTypeParameter) => { {hasPortInfo && verboseSchema && (
const frontendType = param.frontendType || 'text'; <details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text; <summary
style={{
cursor: 'pointer',
color: 'var(--text-secondary)',
fontWeight: 500,
padding: '0.15rem 0',
fontStyle: 'italic',
}}
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
>
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
</summary>
{inputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B07'} {t('Eingabe')}
</div>
{inputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`in-${idx}`}
portIndex={Number(idx)}
schemaNames={def?.accepts ?? []}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
{outputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B06'} {t('Ausgabe')}
</div>
{outputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`out-${idx}`}
portIndex={Number(idx)}
schemaNames={_schemaNamesFromOutputPort(def)}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
</details>
)}
{requiredErrors.length > 0 && (
<div
style={{
marginBottom: 8,
padding: '6px 10px',
background: 'rgba(220,53,69,0.10)',
borderLeft: '3px solid var(--danger-color, #dc3545)',
borderRadius: 4,
fontSize: 12,
color: 'var(--danger-color, #dc3545)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
title={requiredErrorTooltip || undefined}
>
{t('Pflicht-Felder ohne Quelle:')}{' '}
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{extractContentAccordionItems !== null ? (
<>
{extractContentContextParam ? (
<div
key={`${node.id}-${extractContentContextParam.name}`}
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{extractContentContextParam.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && extractContentContextParam.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{extractContentContextParam.type}
</span>
)}
</div>
<ContextBuilderRenderer
param={extractContentContextParam}
value={workflowParamUiValue(params, extractContentContextParam)}
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
) : null}
{extractContentAccordionItems.length > 0 ? (
<AccordionList<string>
key={`${node.id}-extract-accordion`}
defaultOpenId={null}
items={extractContentAccordionItems}
/>
) : null}
</>
) : (
parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row,
// no required-mark, no type-badge. Their value is system-set.
if (param.frontendType === 'hidden') return null;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
const useRequiredPicker = _shouldUseRequiredPicker(param);
if (useRequiredPicker) {
return (
<div key={param.name} style={{ marginBottom: 8 }}>
<RequiredAttributePicker
label={getLabel(param.description, language) || param.name}
expectedType={param.type}
value={workflowParamUiValue(params, param)}
onChange={(val) => updateParam(param.name, val)}
/>
</div>
);
}
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 2,
flexWrap: 'wrap',
minWidth: 0,
}}
>
{param.required && (
<span
title={t('Pflichtfeld')}
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
>
*
</span>
)}
{verboseSchema && param.type && (
<span
title={t('Parameter-Typ')}
style={{
fontSize: 10,
fontWeight: 600,
color: 'var(--text-secondary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{param.type}
</span>
)}
</div>
<Renderer
param={param}
value={workflowParamUiValue(params, param)}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId}
request={request}
nodeType={node.type}
onPatchParams={patchParams}
/>
</div>
);
})
)}
</div>
);
};
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
* fall through to the legacy frontend-type renderer (text/number/select etc.)
* unless they have no frontendType at all and a non-trivial type. */
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
if (!param.required) return false;
if (!param.type) return false;
// Hidden params never get a picker — they are system-set or rendered to
// nothing on purpose. The render loop above also skips hidden rows entirely.
if (param.frontendType === 'hidden') return false;
// Always defer to specialized FE renderers when explicitly chosen.
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
return false;
}
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
if (/^(List\[|Dict\[)/.test(param.type)) return true;
if (/^[A-Z]/.test(param.type)) return true;
return false;
}
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
'userConnection',
'featureInstance',
'sharepointFolder',
'sharepointFile',
'userFileFolder',
'clickupList',
'clickupTask',
'dataRef',
'caseList',
'fieldBuilder',
'keyValueRows',
'cron',
'condition',
'mappingTable',
'filterExpression',
'attachmentBuilder',
'json',
'modelMultiSelect',
]);
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
if (!def?.schema) return [];
if (typeof def.schema === 'string') return [def.schema];
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
return [];
}
interface _PortFieldListProps {
portIndex: number;
schemaNames: string[];
catalog: Record<string, PortSchema>;
emptyLabel: string;
language: string;
}
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
if (!schemaNames.length) return null;
return (
<div style={{ marginLeft: 4, marginBottom: 4 }}>
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
{`#${portIndex} `}{schemaNames.join(' | ')}
</div>
{schemaNames.map((name) => {
const schema = catalog[name];
const fields = schema?.fields ?? [];
if (name === 'Transit') {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
{'\u00B7 Transit (durchgereichte Daten)'}
</div>
);
}
if (!fields.length) {
return (
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
{`\u00B7 ${emptyLabel}`}
</div>
);
}
return ( return (
<Renderer <ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
key={param.name} {fields.map((f) => (
param={param} <li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
value={params[param.name] ?? param.default} <span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
onChange={(val: unknown) => updateParam(param.name, val)} <span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
allParams={params} {!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
instanceId={instanceId} {f.description && (
request={request} <div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
nodeType={node.type} {getLabel(f.description, language)}
/> </div>
)}
</li>
))}
</ul>
); );
})} })}
</div> </div>

View file

@ -1,14 +1,16 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeListItem - Draggable node type item for the sidebar. * NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups. * Used in both regular categories and I/O sub-groups.
*/ */
import React from 'react'; import React from 'react';
import type { NodeType } from '../../../api/workflowApi'; import type { NodeType } from '../../../api/workflowAutomationApi';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils'; import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps { interface NodeListItemProps {

View file

@ -1,15 +1,17 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* NodeSidebar - Sidebar with searchable, collapsible node list. * NodeSidebar - Sidebar with searchable, collapsible node list.
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint). * Groups node types by category (start, input, flow, data, ai, email, sharepoint).
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi'; import type { NodeType, NodeTypeCategory } from '../../../api/workflowAutomationApi';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants'; import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem'; import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -21,7 +23,7 @@ interface NodeSidebarProps {
language: string; language: string;
expandedCategories: Set<string>; expandedCategories: Set<string>;
onToggleCategory: (id: string) => void; onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */ /** Hide palette categories (optional; e.g. feature flags) */
excludedCategories?: Set<string>; excludedCategories?: Set<string>;
style?: React.CSSProperties; style?: React.CSSProperties;
} }

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* RunTracingPanel * RunTracingPanel
* *
@ -7,7 +9,7 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi'; import type { AutoStepLog } from '../../../api/workflowAutomationApi';
import api from '../../../api'; import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -98,7 +100,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
setLoading(true); setLoading(true);
try { try {
const data = await request({ const data = await request({
url: `/api/workflows/${instanceId}/runs/${runId}/steps`, url: `/api/workflow-automation/runs/${runId}/steps`,
method: 'get', method: 'get',
}); });
setSteps(data?.steps || []); setSteps(data?.steps || []);
@ -115,7 +117,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
loadSteps(); loadSteps();
const baseUrl = api.defaults.baseURL || ''; const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`; const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`;
const es = new EventSource(url, { withCredentials: true }); const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es; eventSourceRef.current = es;

View file

@ -1,3 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow. * TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/ */
@ -9,8 +11,8 @@ import {
type AutoWorkflowTemplate, type AutoWorkflowTemplate,
type AutoTemplateScope, type AutoTemplateScope,
type ApiRequestFunction, type ApiRequestFunction,
} from '../../../api/workflowApi'; } from '../../../api/workflowAutomationApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps { interface TemplatePickerProps {
@ -50,7 +52,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
setLoading(true); setLoading(true);
try { try {
const scope = activeScope === 'all' ? undefined : activeScope; const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, instanceId, scope); const result = await fetchTemplates(request, scope);
setTemplates(Array.isArray(result) ? result : result.items); setTemplates(Array.isArray(result) ? result : result.items);
} catch { } catch {
setTemplates([]); setTemplates([]);

View file

@ -1,123 +0,0 @@
/**
* Workflow configuration primary start kind drives the canvas start node.
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
return [
{ value: 'manual', label: t('Manueller Trigger') },
{ value: 'form', label: t('Formular') },
{ value: 'schedule', label: t('Zeitplan') },
{ value: 'always_on', label: t('Immer aktiv') },
];
}
interface WorkflowConfigurationModalProps {
open: boolean;
onClose: () => void;
invocations: WorkflowEntryPoint[];
onApply: (next: WorkflowEntryPoint[]) => void;
}
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
function normalizeLoadedKind(k: string): string {
if (_validKinds.includes(k)) return k;
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
if (k === 'api') return 'manual';
return 'manual';
}
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
onClose,
invocations,
onApply,
}) => {
const { t } = useLanguage();
const kindOptions = _getKindOptions(t);
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
const [titleDe, setTitleDe] = useState('');
useEffect(() => {
if (!open) return;
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
setKind(k);
const entry = invocations[0];
const entryTitle = entry?.title;
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
else setTitleDe('');
}, [open, invocations]);
if (!open) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const label =
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
onApply(next);
onClose();
};
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
<div className={styles.workflowModal}>
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
{t('Workflow-Konfiguration')}
</h3>
<p className={styles.workflowModalHint}>
{t(
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
)}
</p>
<form onSubmit={handleSubmit}>
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
{t('Titel der Start Node')}
</label>
<input
id="wf-start-title"
className={styles.workflowModalInput}
value={titleDe}
onChange={(e) => setTitleDe(e.target.value)}
placeholder={t('z.B. Angebot anlegen')}
/>
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
{kindOptions.map((o) => (
<label key={o.value} className={styles.workflowModalRadio}>
<input
type="radio"
name="kind"
value={o.value}
checked={kind === o.value}
onChange={() => setKind(o.value)}
/>
{o.label}
</label>
))}
</div>
<div className={styles.workflowModalActions}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
{t('Abbrechen')}
</button>
<button type="submit" className={styles.workflowModalBtnPrimary}>
{t('Übernehmen')}
</button>
</div>
</form>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
/** /**
* Automation2 Flow Editor Styles * Workflow Flow Editor Styles
* Sidebar with node list + canvas area. * Sidebar with node list + canvas area.
*/ */
@ -246,6 +246,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0; min-width: 0;
min-height: 0;
background: var(--canvas-bg, #fafafa); background: var(--canvas-bg, #fafafa);
} }
@ -254,6 +255,385 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0); border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
overflow: visible;
}
.canvasHeaderToolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
border-radius: 8px;
border: none;
background: none;
box-sizing: border-box;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderToolbar :global(button),
.canvasHeaderToolbar label {
margin-top: 0;
}
.canvasHeaderEditRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.25rem;
width: 100%;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
.canvasHeaderEditRow :global(button) {
margin-top: 0;
}
.canvasHeaderGhostIconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: transparent;
border-radius: 6px;
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderGhostIconBtn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.06);
}
.canvasHeaderGhostIconBtn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.canvasHeaderZoomCombo {
position: relative;
display: inline-flex;
align-items: stretch;
flex: 0 0 auto;
}
.canvasHeaderZoomInputWrap {
display: inline-flex;
align-items: center;
flex: 0 1 auto;
min-width: 4.25rem;
padding-left: 0.35rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px 0 0 6px;
border-right: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
min-height: 30px;
}
.canvasHeaderZoomInputWrap:focus-within {
border-color: var(--primary-color, #007bff);
}
.canvasHeaderZoomInput {
flex: 1 1 auto;
width: 2.25rem;
min-width: 0;
padding: 0.28rem 0.15rem 0.28rem 0;
font-size: 0.8125rem;
border: none;
background: transparent;
color: var(--text-primary, #333);
text-align: right;
box-sizing: border-box;
min-height: 28px;
}
.canvasHeaderZoomInput:focus {
outline: none;
}
.canvasHeaderZoomSuffix {
flex-shrink: 0;
padding-right: 0.35rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
user-select: none;
}
.canvasHeaderZoomChevronBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
min-height: 30px;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 0 6px 6px 0;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
cursor: pointer;
box-sizing: border-box;
}
.canvasHeaderZoomChevronBtn:hover {
background: rgba(0, 0, 0, 0.06);
}
/* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 1 12.5rem;
min-width: 8rem;
max-width: 100%;
padding: 0.31rem 0.45rem;
min-height: 30px;
box-sizing: border-box;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #ccc);
border-radius: var(--button-border-radius, 6px);
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
gap: 0.25rem;
}
.canvasHeaderTitle,
.canvasHeaderTitle input {
margin: 0;
min-width: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.canvasHeaderTitle {
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.canvasHeaderTitleMuted {
font-style: italic;
font-weight: 500;
opacity: 0.65;
color: var(--text-secondary, #666);
}
.canvasHeaderTitle input {
width: 100%;
max-width: 100%;
padding: 0.25rem 0.4rem;
border: 1px solid var(--primary-color, #007bff);
border-radius: 4px;
outline: none;
background: var(--bg-primary, #fff);
box-sizing: border-box;
}
.canvasHeaderActionPanel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.4rem;
padding: 0.35rem 0.5rem;
border-radius: 8px;
border: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-secondary, #f8f9fa);
flex: 0 1 auto;
max-width: 100%;
margin-left: auto;
}
.canvasHeaderSplitPair :global(.button + .button) {
margin-left: 0;
}
.canvasHeaderRunBlocked {
background: rgba(220, 53, 69, 0.1) !important;
border: 1px solid var(--danger-color, #dc3545) !important;
color: var(--danger-color, #dc3545) !important;
cursor: help !important;
box-shadow: none !important;
}
.canvasHeaderRunBlocked:hover:not(:disabled) {
filter: brightness(0.97);
}
.canvasHeaderRunBlocked :global(.buttonIcon) {
opacity: 0.5;
}
.canvasHeaderVersionRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border-color, #e8e8e8);
width: 100%;
}
.canvasHeaderVersionRow :global(.button) {
margin-top: 0;
}
.canvasHeaderVersionLabel {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
flex: 0 0 auto;
}
.canvasHeaderVersionBadge {
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
background: var(--canvasHeaderBadgeBg, transparent);
color: var(--canvasHeaderBadgeFg, inherit);
flex: 0 0 auto;
}
.canvasHeaderVersionAction {
font-size: 0.8rem !important;
padding: 0.25rem 0.6rem !important;
min-height: auto !important;
}
.canvasHeaderVersionSpinner {
font-size: 0.85rem;
}
.canvasHeaderExecuteBanner {
margin-top: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.875rem;
}
.canvasHeaderExecuteBannerSuccess {
background: rgba(40, 167, 69, 0.15);
color: var(--success-color, #28a745);
}
.canvasHeaderExecuteBannerWarning {
background: rgba(255, 193, 7, 0.15);
color: var(--warning-color, #ffc107);
}
.canvasHeaderExecuteBannerPaused {
background: rgba(0, 123, 255, 0.15);
color: var(--primary-color, #007bff);
}
.canvasHeaderExecuteBannerError {
background: rgba(220, 53, 69, 0.15);
color: var(--danger-color, #dc3545);
}
.canvasHeaderSysadminInput {
margin: 0;
}
.canvasHeaderVersionSelect {
width: 11rem;
max-width: 100%;
padding: 0.3rem 0.45rem;
font-size: 0.85rem;
min-height: 1.9rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderSysadmin {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
padding: 0.2rem 0.45rem;
border: 1px dashed var(--border-color, #ccc);
border-radius: 4px;
cursor: pointer;
user-select: none;
white-space: nowrap;
flex: 0 0 auto;
}
.canvasHeaderNewSplit {
position: relative;
display: inline-flex;
flex: 0 0 auto;
}
.canvasHeaderSplitPair {
display: flex;
flex: 0 0 auto;
}
.canvasHeaderNewSplitMain {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.canvasHeaderNewSplitMenu {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
padding-left: 0.25rem;
padding-right: 0.4rem;
border-left: 1px solid rgba(0, 0, 0, 0.12);
}
.canvasHeaderMenuDropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
min-width: 11rem;
margin-top: 0.25rem;
}
.canvasHeaderMenuItem {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
cursor: pointer;
font-size: 0.85rem;
color: var(--text-primary, #333);
}
.canvasHeaderMenuItem:hover {
background: var(--bg-hover, #e9ecef);
}
.canvasHeaderMenuItem + .canvasHeaderMenuItem {
border-top: 1px solid var(--border-color, #e0e0e0);
} }
.canvasTitle { .canvasTitle {
@ -265,22 +645,183 @@
.canvasArea { .canvasArea {
flex: 1; flex: 1;
padding: 2rem; padding: 0;
min-height: 400px; min-height: 0;
overflow: hidden; overflow-x: visible;
overflow-y: hidden;
} }
.canvasDropZone { .canvasDropZone {
position: relative; position: relative;
min-height: 100%; min-height: 100%;
height: 100%; height: 100%;
overflow: hidden; /* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
overflow: visible;
border-radius: 8px; border-radius: 8px;
/* Infinite grid: on viewport, moves with pan/zoom via inline style */ /* Infinite grid: on viewport, moves with pan/zoom via inline style */
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px); background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
background-repeat: repeat; background-repeat: repeat;
} }
.canvasDropZoneConnectionTool {
cursor: crosshair;
}
.canvasStickyNote {
position: relative;
pointer-events: auto;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteResize {
position: absolute;
right: 1px;
bottom: 1px;
width: 16px;
height: 16px;
padding: 0;
margin: 0;
border: none;
border-radius: 2px 0 6px 0;
cursor: nwse-resize;
z-index: 3;
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.12) 45%,
rgba(0, 0, 0, 0.12) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.18) 58%,
rgba(0, 0, 0, 0.18) 64%,
transparent 64%
);
box-sizing: border-box;
}
.canvasStickyNoteResize:hover {
background: linear-gradient(
135deg,
transparent 0%,
transparent 45%,
rgba(0, 0, 0, 0.2) 45%,
rgba(0, 0, 0, 0.2) 50%,
transparent 50%,
transparent 58%,
rgba(0, 0, 0, 0.26) 58%,
rgba(0, 0, 0, 0.26) 64%,
transparent 64%
);
}
.canvasStickyNoteResize:focus-visible {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteSelected {
box-shadow:
0 0 0 2px var(--primary-color, #007bff),
0 1px 4px rgba(0, 0, 0, 0.08);
}
.canvasStickyNoteToolbar {
display: flex;
align-items: center;
gap: 0.35rem;
min-height: 1.5rem;
padding: 0.15rem 0.25rem 0.2rem;
background: rgba(0, 0, 0, 0.06);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: grab;
user-select: none;
}
.canvasStickyNoteToolbar:active {
cursor: grabbing;
}
.canvasStickyNoteGrip {
flex: 1;
font-size: 0.7rem;
letter-spacing: -0.12em;
color: var(--text-muted, #666);
opacity: 0.85;
padding: 0 0.15rem;
}
.canvasStickyNoteSwatches {
display: flex;
flex-wrap: wrap;
gap: 3px;
justify-content: flex-end;
}
.canvasStickyNoteSwatch {
width: 14px;
height: 14px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.22);
padding: 0;
cursor: pointer;
flex-shrink: 0;
box-sizing: border-box;
}
.canvasStickyNoteSwatch:hover {
filter: brightness(0.96);
}
.canvasStickyNoteSwatchActive {
outline: 2px solid var(--primary-color, #007bff);
outline-offset: 1px;
}
.canvasStickyNoteBody {
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
color: var(--text-primary, #333);
border: 1px solid transparent;
border-radius: 0;
white-space: pre-wrap;
word-break: break-word;
cursor: text;
outline: none;
}
.canvasStickyNoteBody:focus-visible {
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.35);
}
.canvasStickyNoteTextarea {
display: block;
width: 100%;
margin: 0;
min-height: 0;
padding: 0.45rem 0.55rem;
font-size: 0.8125rem;
line-height: 1.35;
font-family: inherit;
color: var(--text-primary, #333);
border-style: solid;
border-width: 1px;
border-radius: 0;
box-shadow: none;
resize: none;
box-sizing: border-box;
outline: none;
}
.canvasStickyNoteTextarea:focus {
border-color: var(--primary-color, #007bff);
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.25);
}
.canvasContent { .canvasContent {
position: absolute; position: absolute;
left: 0; left: 0;
@ -476,6 +1017,8 @@
.handleWrapper:has(.handleOutput) { .handleWrapper:has(.handleOutput) {
flex-direction: row; flex-direction: row;
/* Bottom handles: keep circle math aligned with wires even when a label grows row height. */
align-items: flex-end;
} }
.handleWrapper:has(.handleInput) { .handleWrapper:has(.handleInput) {
@ -507,20 +1050,45 @@
cursor: copy; cursor: copy;
} }
/* Node Config Panel */ /* Shell: stretches to full canvas-area height so inner `.nodeConfigPanel` can scroll. */
.nodeConfigPanelWrap {
flex-shrink: 0;
align-self: stretch;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
/* Node Config Panel
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
* pair acts as a safety net so long unbreakable strings (type names like
* `List[ActionDocument]`, hashed IDs, refs like ` node.path field`) can
* never push content out of the panel frame. Children rely on this; e.g.
* `RequiredAttributePicker` lays out label/badge so the badge wraps below
* a long label rather than escaping to the right.
*/
.nodeConfigPanel { .nodeConfigPanel {
flex: 1;
min-height: 0;
padding: 1rem; padding: 1rem;
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0); border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px; width: 280px;
flex-shrink: 0; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
min-width: 0; min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
position: relative;
z-index: 10;
} }
.nodeConfigPanel h4 { .nodeConfigPanel h4 {
margin: 0 0 0.75rem 0; margin: 0 0 0.75rem 0;
font-size: 0.9rem; font-size: 0.9rem;
overflow-wrap: anywhere;
} }
.nodeConfigNameRow { .nodeConfigNameRow {
@ -547,6 +1115,8 @@
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
line-height: 1.4; line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
} }
.nodeConfigPanel label { .nodeConfigPanel label {
@ -572,9 +1142,12 @@
min-height: 60px; min-height: 60px;
} }
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips */ /* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.nodeConfigPanel .nodeConfigPanel
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) { button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
[data-accordion-header]
):not([data-schedule-day]) {
margin-top: 0.5rem; margin-top: 0.5rem;
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
@ -667,6 +1240,12 @@
background: rgba(220, 53, 69, 0.1); background: rgba(220, 53, 69, 0.1);
} }
.formFieldOptionsBlock {
margin-top: 0.4rem;
padding-top: 0.45rem;
border-top: 1px solid var(--border-color, #e8e8e8);
}
/* Upload node config */ /* Upload node config */
.uploadNodeConfig { .uploadNodeConfig {
display: flex; display: flex;
@ -1257,24 +1836,6 @@
cursor: pointer; cursor: pointer;
} }
.canvasGearBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
cursor: pointer;
font-size: 1rem;
}
.canvasGearBtn:hover {
background: var(--bg-hover, #f0f0f0);
}
.startsInput, .startsInput,
.startsSelect { .startsSelect {
padding: 0.35rem 0.5rem; padding: 0.35rem 0.5rem;
@ -1284,53 +1845,112 @@
min-width: 0; min-width: 0;
} }
/* Data Picker */ /* Data Picker rendered with createPortal(document.body) so it is not affected
by .nodeConfigPanels generic CTA `button` styles. */
.dataPickerOverlay { .dataPickerOverlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.35); background: rgba(0, 0, 0, 0.4);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1000; z-index: 11000;
padding: 1rem;
box-sizing: border-box;
} }
.dataPickerModal { .dataPickerModal {
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
border-radius: 8px; color: var(--text-primary, #1a1a1a);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); border-radius: 10px;
max-width: 420px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
max-height: 80vh; border: 1px solid var(--border-color, #e0e0e0);
max-width: min(420px, 100vw - 2rem);
width: 100%;
max-height: min(80vh, 640px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
} }
.dataPickerHeader { .dataPickerHeader {
display: flex; display: flex;
align-items: center; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
padding: 1rem 1.25rem; gap: 0.75rem;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--border-color, #e0e0e0); border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.dataPickerHeaderControls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
} }
.dataPickerTitle { .dataPickerTitle {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1a1a);
line-height: 1.35;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.4rem;
min-width: 0;
}
.dataPickerTypeBadge {
display: inline-block;
font-size: 0.7rem;
font-weight: 400;
font-family: ui-monospace, 'Cascadia Code', monospace;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f0f0f0);
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 0.1rem 0.45rem;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataPickerStrictLabel {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--text-secondary, #666);
user-select: none;
} }
.dataPickerClose { .dataPickerClose {
background: none; display: inline-flex;
border: none; align-items: center;
font-size: 1.5rem; justify-content: center;
cursor: pointer; width: 2rem;
color: var(--text-secondary, #666); height: 2rem;
padding: 0 0.25rem; flex-shrink: 0;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 6px;
font-size: 1.25rem;
line-height: 1; line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
} }
.dataPickerClose:hover { .dataPickerClose:hover {
color: var(--text-primary, #333); background: var(--bg-hover, #e9ecef);
color: var(--text-primary, #1a1a1a);
border-color: var(--border-color, #b8b8b8);
} }
.dataPickerBody { .dataPickerBody {
@ -1345,24 +1965,35 @@
} }
.dataPickerNodeSection { .dataPickerNodeSection {
margin-bottom: 0.75rem; margin-bottom: 0.5rem;
} }
/* Expandable source row: neutral “list row”, not a primary CTA. */
.dataPickerNodeHeader { .dataPickerNodeHeader {
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 0.5rem 0; box-sizing: border-box;
background: none; padding: 0.5rem 0.6rem;
border: none; background: var(--bg-secondary, #f4f5f7);
border: 1px solid var(--border-color, #dde1e5);
border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.85rem;
text-align: left; text-align: left;
color: var(--text-primary, #1a1a1a);
margin: 0;
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
} }
.dataPickerNodeHeader:hover { .dataPickerNodeHeader:hover {
background: var(--bg-hover, #f5f5f5); background: var(--bg-hover, #e9ebef);
border-radius: 4px; border-color: var(--border-color, #c8cfd6);
}
.dataPickerNodeHeader:focus-visible {
outline: 2px solid var(--primary-color, #4a6fa5);
outline-offset: 1px;
} }
.dataPickerExpandIcon { .dataPickerExpandIcon {
@ -1401,6 +2032,105 @@
border-color: var(--primary-color, #007bff); border-color: var(--primary-color, #007bff);
} }
/* Hover safety net: every nested span in a leaf inherits the white text so
* type-hints and meta info stay readable on the blue hover background. */
.dataPickerLeaf:hover * {
color: inherit;
}
/* Inline type-hint after a leaf label, e.g. "documents (List[ActionDocument])". */
.dataPickerLeafType {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Schema-name hint on the node-section header row. */
.dataPickerNodeSchemaHint {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
.dataPickerMismatchBadge {
font-size: 10px;
margin-left: 4px;
color: var(--color-warning, #f59e0b);
flex-shrink: 0;
}
/* Recommended pick: subtle highlight on the row */
.dataPickerLeafRecommended {
font-weight: 500;
}
/* "Empfohlen" pill shown on recommended entries */
.dataPickerRecommendedPill {
display: inline-block;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 1px 5px;
border-radius: 10px;
margin-left: 5px;
background: var(--color-primary-light, #dbeafe);
color: var(--color-primary, #2563eb);
flex-shrink: 0;
vertical-align: middle;
}
/* "iterieren" affordance visually distinct (subtle accent), readable on
* the picker's white background and on the leaf's blue hover background. */
.dataPickerIterateBtn {
font-size: 10px;
padding: 2px 6px;
background: var(--bg-secondary, #f5f7fa);
color: var(--primary-color, #007bff);
border: 1px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.dataPickerIterateBtn:hover {
background: var(--primary-color, #007bff);
color: #fff;
border-color: var(--primary-color, #007bff);
}
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */ /* Dynamic Value Field */
.dynamicValueField { .dynamicValueField {
display: flex; display: flex;

View file

@ -1,8 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Automation2FlowEditor * WorkflowFlowEditor
* *
* n8n-style flow builder with backend-driven node list. * n8n-style flow builder with backend-driven node list and categories.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync. * Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
*/ */
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
@ -22,29 +24,35 @@ import {
archiveVersion, archiveVersion,
createTemplateFromWorkflow, createTemplateFromWorkflow,
copyTemplate, copyTemplate,
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
type NodeType, type NodeType,
type NodeTypeCategory, type NodeTypeCategory,
type Automation2Graph, type WorkflowGraph,
type Automation2Workflow, type WorkflowDefinition,
type ExecuteGraphResponse, type ExecuteGraphResponse,
type WorkflowEntryPoint, type WorkflowEntryPoint,
type AutoVersion, type AutoVersion,
type AutoTemplateScope, type AutoTemplateScope,
} from '../../../api/workflowApi'; } from '../../../api/workflowAutomationApi';
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas'; import {
FlowCanvas,
type CanvasNode,
type CanvasConnection,
type CanvasStickyNote,
type FlowCanvasHandle,
type FlowCanvasViewportEditState,
} from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar'; import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader'; import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker'; import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils'; import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils'; import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext'; import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt'; import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel'; import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
@ -52,16 +60,28 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
import { RunTracingPanel } from './RunTracingPanel'; import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css'; import styles from './WorkflowFlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] => const LOG = '[WorkflowEditor]';
buildInvocationsForPrimaryKind('manual', [], runLabel);
interface Automation2FlowEditorProps { const CANVAS_HISTORY_MAX = 50;
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
return {
nodes: nodes.map((n) => ({
...n,
parameters: n.parameters ? { ...n.parameters } : {},
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
})),
connections: connections.map((c) => ({ ...c })),
};
}
interface WorkflowFlowEditorProps {
instanceId: string; instanceId: string;
mandateId?: string; mandateId?: string;
language?: string; language?: string;
@ -75,7 +95,7 @@ interface Automation2FlowEditorProps {
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
} }
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId, export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
mandateId, mandateId,
language = 'de', language = 'de',
initialWorkflowId, initialWorkflowId,
@ -93,24 +113,38 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [categories, setCategories] = useState<NodeTypeCategory[]>([]); const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({}); const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({}); const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowAutomationApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowAutomationApi').ConditionOperatorDef[]>
>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee']) new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
); );
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]); const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]); const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
const suppressCanvasHistoryRef = useRef(false);
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
zoom: 1,
selectedNodeCount: 0,
connectionSelected: false,
stickyNoteSelected: false,
});
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
const [executing, setExecuting] = useState(false); const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null); const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]); const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null); const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null); const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() => const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true); const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null); const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({}); const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
@ -122,10 +156,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
instanceId, instanceId,
mandateId: mandateId || '', mandateId: mandateId || '',
featureInstanceId: instanceId, featureInstanceId: instanceId,
surface: 'workflowAutomation',
}), [instanceId, mandateId]); }), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]); const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null); const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const didBootstrapEmptyCanvasRef = useRef(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => { const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
@ -133,6 +171,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [sidebarWidth, setSidebarWidth] = useState(() => { const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; } try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
}); });
// Verbose schema toggle: shows the static type-reference block (input/output
// schema) and parameter type-badges in NodeConfigPanel. Only the
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
const [verboseSchema, setVerboseSchema] = useState(() => {
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
}, [verboseSchema]);
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null); const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
useEffect(() => { useEffect(() => {
@ -170,7 +217,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
document.body.style.userSelect = 'none'; document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]); }, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []); const startNodeTypeIds = useMemo(
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
[nodeTypes]
);
const hasCanvasStartNode = useMemo(
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
[canvasNodes, startNodeTypeIds]
);
const missingStartNodeBlocking = useMemo(
() => canvasNodes.length > 0 && !hasCanvasStartNode,
[canvasNodes.length, hasCanvasStartNode]
);
const nodeOutputsPreview = useMemo( const nodeOutputsPreview = useMemo(
() => () =>
@ -178,26 +236,92 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[canvasNodes, nodeTypes, executeResult?.nodeOutputs] [canvasNodes, nodeTypes, executeResult?.nodeOutputs]
); );
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
// canvas error badges and the Run-button gate. Graph-level: Save stays
// unconditional (Schicht-4 invariant: WIP must always be persistable).
const nodeErrors = useMemo(
() =>
findGraphErrors(
canvasNodes,
nodeTypes,
(p) => getParamLabel(p.description, language) || p.name,
),
[canvasNodes, nodeTypes, language]
);
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
if (suppressCanvasHistoryRef.current) return;
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const past = canvasHistoryPastRef.current;
const last = past[past.length - 1];
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
past.push(snap);
if (past.length > CANVAS_HISTORY_MAX) past.shift();
canvasHistoryFutureRef.current = [];
setCanvasHistoryTick((x) => x + 1);
}, [canvasNodes, canvasConnections]);
const onCanvasHistoryCheckpoint = useCallback(() => {
pushCanvasHistoryPastFromCurrent();
}, [pushCanvasHistoryPastFromCurrent]);
const undoCanvasEdit = useCallback(() => {
const past = canvasHistoryPastRef.current;
if (past.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = past.pop()!;
canvasHistoryFutureRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const redoCanvasEdit = useCallback(() => {
const fut = canvasHistoryFutureRef.current;
if (fut.length === 0) return;
suppressCanvasHistoryRef.current = true;
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
const restored = fut.pop()!;
canvasHistoryPastRef.current.push(currentSnap);
setCanvasNodes(restored.nodes);
setCanvasConnections(restored.connections);
setCanvasHistoryTick((x) => x + 1);
requestAnimationFrame(() => {
suppressCanvasHistoryRef.current = false;
});
flowCanvasRef.current?.focusCanvas();
}, [canvasNodes, canvasConnections]);
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
const applyGraphWithSync = useCallback( const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => { (
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen')); graph: WorkflowGraph | null | undefined,
setInvocations(inv); wfInvocations: WorkflowEntryPoint[] | undefined,
if (!graph?.nodes?.length) { opts?: { skipHistory?: boolean }
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language); ) => {
setCanvasNodes(synced.nodes); if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
setCanvasConnections(synced.connections); pushCanvasHistoryPastFromCurrent();
return;
} }
const { nodes, connections } = fromApiGraph(graph, nodeTypes); setInvocations(wfInvocations ?? []);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language); const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
setCanvasNodes(synced.nodes); const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasConnections(synced.connections); setCanvasNodes(nodes);
setCanvasConnections(connections);
}, },
[nodeTypes, language, t] [nodeTypes, pushCanvasHistoryPastFromCurrent]
); );
const handleFromApiGraph = useCallback( const handleFromApiGraph = useCallback(
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => { (graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations); applyGraphWithSync(graph, wfInvocations);
}, },
[applyGraphWithSync] [applyGraphWithSync]
@ -209,11 +333,31 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') }); setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
return; return;
} }
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
if (Object.keys(nodeErrors).length > 0) {
const firstId = Object.keys(nodeErrors)[0];
const firstNode = canvasNodes.find((n) => n.id === firstId);
if (firstNode) setSelectedNode(firstNode);
setExecuteResult({
success: false,
error:
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
});
return;
}
if (missingStartNodeBlocking) {
setExecuteResult({
success: false,
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
});
return;
}
setExecuting(true); setExecuting(true);
setExecuteResult(null); setExecuteResult(null);
try { try {
const ep = currentWorkflowId ? invocations[0]?.id : undefined; const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, { const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}), ...(ep ? { entryPointId: ep } : {}),
}); });
setExecuteResult(result); setExecuteResult(result);
@ -226,7 +370,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setExecuting(false); setExecuting(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections); const graph = toApiGraph(canvasNodes, canvasConnections);
@ -234,11 +378,41 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') }); setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return; return;
} }
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
const errorCount = Object.values(nodeErrors).reduce(
(acc, list) => acc + list.length,
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => {
const parts: string[] = [];
if (errorCount > 0) {
parts.push(
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
);
}
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
}
return {
success: true,
warning: parts.length ? parts.join(' ') : undefined,
};
};
setSaving(true); setSaving(true);
try { try {
if (currentWorkflowId) { if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); const updated = await updateWorkflow(request, currentWorkflowId, {
setExecuteResult({ success: true } as ExecuteGraphResponse); graph,
invocations,
targetFeatureInstanceId,
});
setInvocations(updated.invocations ?? []);
setExecuteResult(_buildSaveResult());
} else { } else {
const label = await promptInput(t('Workflow-Name:'), { const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'), title: t('Workflow speichern'),
@ -249,40 +423,64 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setSaving(false); setSaving(false);
return; return;
} }
const created = await createWorkflow(request, instanceId, { const created = await createWorkflow(request, {
label: label.trim() || t('Neuer Workflow'), label: label.trim() || t('Neuer Workflow'),
graph, graph,
invocations, invocations,
targetFeatureInstanceId,
mandateId,
}); });
setCurrentWorkflowId(created.id); setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations); setInvocations(created.invocations ?? []);
setWorkflows((prev) => [...prev, created]); setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse); setExecuteResult(_buildSaveResult());
} }
} catch (err: unknown) { } catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) }); setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]); }, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
const handleLoad = useCallback( const handleLoad = useCallback(
async (workflowId: string) => { async (workflowId: string) => {
try { try {
const wf = await fetchWorkflow(request, instanceId, workflowId); const wf = await fetchWorkflow(request, workflowId);
if (wf.graph) { if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations); handleFromApiGraph(wf.graph, wf.invocations);
} else { } else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
} }
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
const next = prev.slice();
next[idx] = { ...prev[idx], ...wf };
return next;
});
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, []);
try {
const result = await fetchWorkflows(request);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr);
}
return;
}
setExecuteResult({ setExecuteResult({
success: false, success: false,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
} }
}, },
[request, instanceId, handleFromApiGraph, applyGraphWithSync] [request, handleFromApiGraph, applyGraphWithSync, t]
); );
const handleWorkflowSelect = useCallback( const handleWorkflowSelect = useCallback(
@ -291,7 +489,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
if (workflowId) handleLoad(workflowId); if (workflowId) handleLoad(workflowId);
else { else {
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
} }
}, },
[handleLoad, applyGraphWithSync, t] [handleLoad, applyGraphWithSync, t]
@ -300,36 +498,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleNew = useCallback(() => { const handleNew = useCallback(() => {
setCurrentWorkflowId(null); setCurrentWorkflowId(null);
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); applyGraphWithSync({ nodes: [], connections: [] }, []);
}, [applyGraphWithSync, t]); }, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => { const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const next = { ...n, parameters }; const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) { if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(parameters.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => { const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) => setCanvasNodes((prev) => {
prev.map((n) => { const nextNodes = prev.map((n) => {
if (n.id !== nodeId) return n; if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch }; const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged }; const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) { if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? []; const newCount = switchOutputCountFromCases(merged.cases);
next.outputs = Math.max(1, cases.length); next.outputs = newCount;
setCanvasConnections((conns) =>
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
);
} }
return next; return next;
}) });
); return nextNodes;
});
}, []); }, []);
const handleNodeUpdate = useCallback( const handleNodeUpdate = useCallback(
@ -341,24 +547,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[] []
); );
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => { const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchNodeTypes(request, instanceId, language); const data = await fetchNodeTypes(request, mandateId || '', language);
setNodeTypes(data.nodeTypes); setNodeTypes(data.nodeTypes);
setCategories(data.categories); setCategories(data.categories);
if (data.portTypeCatalog) { if (data.portTypeCatalog) {
@ -366,6 +559,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setRegistryCatalog(data.portTypeCatalog as never); setRegistryCatalog(data.portTypeCatalog as never);
} }
if (data.systemVariables) setSystemVariables(data.systemVariables); if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err)); setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]); setNodeTypes([]);
@ -373,17 +568,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [instanceId, language, request]); }, [language, request]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try { try {
const result = await fetchWorkflows(request, instanceId); const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
setWorkflows(Array.isArray(result) ? result : result.items); setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) { } catch (e) {
console.error(`${LOG} loadWorkflows failed`, e); console.error(`${LOG} loadWorkflows failed`, e);
} }
}, [instanceId, request]); }, [request, mandateId]);
useEffect(() => { useEffect(() => {
loadNodeTypes(); loadNodeTypes();
@ -393,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
loadWorkflows(); loadWorkflows();
}, [loadWorkflows]); }, [loadWorkflows]);
useEffect(() => {
setCanvasStickyNotes([]);
}, [currentWorkflowId]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined); const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => { useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return; if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
@ -403,17 +601,34 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
useEffect(() => { useEffect(() => {
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return; if (currentWorkflowId || initialWorkflowId) {
if (canvasNodes.length > 0) return; didBootstrapEmptyCanvasRef.current = false;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen'))); return;
}
if (didBootstrapEmptyCanvasRef.current) return;
didBootstrapEmptyCanvasRef.current = true;
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
return;
}
console.debug(`${LOG} bootstrapping empty canvas`, {
currentWorkflowId,
initialWorkflowId,
canvasNodes: canvasNodes.length,
canvasConnections: canvasConnections.length,
invocations: invocations.length,
});
applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true,
});
}, [ }, [
loading, loading,
nodeTypes.length, nodeTypes.length,
currentWorkflowId, currentWorkflowId,
initialWorkflowId, initialWorkflowId,
canvasNodes.length, canvasNodes.length,
canvasConnections.length,
invocations.length,
applyGraphWithSync, applyGraphWithSync,
t,
]); ]);
const toggleCategory = useCallback((id: string) => { const toggleCategory = useCallback((id: string) => {
@ -427,7 +642,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handleDropNodeType = useCallback( const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => { (nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId); const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return; if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@ -453,17 +667,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
); );
const loadVersions = useCallback(async () => { const loadVersions = useCallback(async () => {
if (!instanceId || !currentWorkflowId) { if (!currentWorkflowId) {
setVersions([]); setVersions([]);
return; return;
} }
try { try {
const v = await fetchVersions(request, instanceId, currentWorkflowId); const v = await fetchVersions(request, currentWorkflowId);
setVersions(v); setVersions(v);
} catch (e) { } catch (e) {
console.error(`${LOG} loadVersions failed`, e); console.error(`${LOG} loadVersions failed`, e);
} }
}, [instanceId, currentWorkflowId, request]); }, [currentWorkflowId, request]);
useEffect(() => { useEffect(() => {
loadVersions(); loadVersions();
@ -484,10 +698,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const handlePublishVersion = useCallback( const handlePublishVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await publishVersion(request, instanceId, versionId); await publishVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -495,15 +708,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleUnpublishVersion = useCallback( const handleUnpublishVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await unpublishVersion(request, instanceId, versionId); await unpublishVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -511,15 +723,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleArchiveVersion = useCallback( const handleArchiveVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await archiveVersion(request, instanceId, versionId); await archiveVersion(request, versionId);
await loadVersions(); await loadVersions();
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -527,14 +738,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, instanceId, loadVersions] [request, loadVersions]
); );
const handleCreateDraft = useCallback(async () => { const handleCreateDraft = useCallback(async () => {
if (!instanceId || !currentWorkflowId) return; if (!currentWorkflowId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
const draft = await createDraftVersion(request, instanceId, currentWorkflowId); const draft = await createDraftVersion(request, currentWorkflowId);
await loadVersions(); await loadVersions();
setCurrentVersionId(draft.id); setCurrentVersionId(draft.id);
} catch (e: unknown) { } catch (e: unknown) {
@ -542,16 +753,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setVersionLoading(false); setVersionLoading(false);
} }
}, [request, instanceId, currentWorkflowId, loadVersions]); }, [request, currentWorkflowId, loadVersions]);
// Template: save current workflow as template // Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false); const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback( const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => { async (scope: AutoTemplateScope) => {
if (!instanceId || !currentWorkflowId) return; if (!currentWorkflowId) return;
setTemplateSaving(true); setTemplateSaving(true);
try { try {
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope); await createTemplateFromWorkflow(request, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse); setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) { } catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
@ -559,16 +770,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setTemplateSaving(false); setTemplateSaving(false);
} }
}, },
[request, instanceId, currentWorkflowId] [request, currentWorkflowId]
); );
// Template: new workflow from template // Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback( const handleNewFromTemplate = useCallback(
async (templateId: string) => { async (templateId: string) => {
if (!instanceId) return;
try { try {
const wf = await copyTemplate(request, instanceId, templateId); const wf = await copyTemplate(request, templateId);
setWorkflows((prev) => [...prev, wf]); setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id); setCurrentWorkflowId(wf.id);
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations); if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
@ -577,21 +787,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} }
}, },
[request, instanceId, handleFromApiGraph] [request, handleFromApiGraph]
); );
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
console.error(`${LOG} rename failed`, e);
}
}, [request, instanceId]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
}, [canvasConnections]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]); const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
@ -633,7 +834,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language} language={language}
expandedCategories={expandedCategories} expandedCategories={expandedCategories}
onToggleCategory={toggleCategory} onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle} style={_sidebarStyle}
/> />
); );
@ -641,15 +841,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected = const configurableSelected =
selectedNode && selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) => [
selectedNode.type.startsWith(p) 'input.',
); 'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
const canvasHeaderEdit = useMemo(
() => ({
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
connectionSelected: canvasViewportEdit.connectionSelected,
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
connectionToolActive: canvasConnectionToolActive,
canUndo: canCanvasUndo,
canRedo: canCanvasRedo,
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
onResetView: () => flowCanvasRef.current?.resetView(),
onUndo: undoCanvasEdit,
onRedo: redoCanvasEdit,
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
}),
[
canvasViewportEdit,
canvasConnectionToolActive,
canCanvasUndo,
canCanvasRedo,
undoCanvasEdit,
redoCanvasEdit,
]
);
return ( return (
<div className={styles.container}> <div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */} {/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<> {leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}> <div
data-suppress-flow-node-hotkeys=""
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
<button <button
@ -699,7 +945,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
activeTab={udbTab as UdbTab} activeTab={udbTab as UdbTab}
onTabChange={(tab) => setUdbTab(tab as LeftTab)} onTabChange={(tab) => setUdbTab(tab as LeftTab)}
hideTabs={['chats']} hideTabs={['chats']}
onFileSelect={onFileSelect} onFileSelect={async (fileId, fileName) => {
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
try {
const result = await importWorkflowFromFile(request, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
} catch (e) {
console.error('[workflowAutomation] workflow file import failed', e);
}
return;
}
onFileSelect?.(fileId, fileName);
}}
onSourcesChanged={onSourcesChanged} onSourcesChanged={onSourcesChanged}
/> />
)} )}
@ -717,11 +975,24 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNew={handleNew} onNew={handleNew}
onSave={handleSave} onSave={handleSave}
onExecute={handleExecute} onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)} onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)} workspacePanelOpen={leftPanelOpen}
saving={saving} saving={saving}
executing={executing} executing={executing}
hasNodes={canvasNodes.length > 0} hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: missingStartNodeBlocking
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
: null
}
onExecuteBlockedClick={() => {
if (firstErrorNodeId) {
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
if (n) setSelectedNode(n);
}
}}
executeResult={executeResult} executeResult={executeResult}
versions={versions} versions={versions}
currentVersionId={currentVersionId} currentVersionId={currentVersionId}
@ -734,12 +1005,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSaveAsTemplate={handleSaveAsTemplate} onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving} templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)} onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename} verboseSchema={verboseSchema}
onAutoLayout={handleAutoLayout} onVerboseSchemaChange={setVerboseSchema}
canvasEdit={canvasHeaderEdit}
/> />
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}> <div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
<FlowCanvas <FlowCanvas
ref={flowCanvasRef}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@ -750,10 +1023,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
getCategoryIcon={getCategoryIcon} getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode} onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined} highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onViewportEditState={setCanvasViewportEdit}
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
onConnectionToolActiveChange={setCanvasConnectionToolActive}
stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow') return false;
const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id;
if (!fileId) return false;
try {
const result = await importWorkflowFromFile(request, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
} catch (e) {
console.error(`${LOG} workflow drop import failed`, e);
return false;
}
}}
/> />
</div> </div>
{configurableSelected && selectedNode && ( {configurableSelected && selectedNode && (
<Automation2DataFlowProvider <div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<WorkflowDataFlowProvider
node={selectedNode} node={selectedNode}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
@ -762,6 +1057,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language} language={language}
portTypeCatalog={portTypeCatalog as Record<string, never>} portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>} systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
conditionOperatorCatalog={conditionOperatorCatalog}
instanceId={instanceId}
request={request}
> >
<NodeConfigPanel <NodeConfigPanel
node={selectedNode} node={selectedNode}
@ -772,15 +1071,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNodeUpdate={handleNodeUpdate} onNodeUpdate={handleNodeUpdate}
instanceId={instanceId} instanceId={instanceId}
request={request} request={request}
verboseSchema={verboseSchema}
/> />
</Automation2DataFlowProvider> </WorkflowDataFlowProvider>
</div>
)} )}
</div> </div>
</div> </div>
{/* Right panel: Nodes + Tracing tabs */} {/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} /> <div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}> <div
data-suppress-flow-node-hotkeys=""
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
>
<div className={styles.rightTabBar}> <div className={styles.rightTabBar}>
<button <button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`} className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
@ -813,12 +1117,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
</div> </div>
<PromptDialog /> <PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker <TemplatePicker
open={templatePickerOpen} open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)} onClose={() => setTemplatePickerOpen(false)}
@ -830,4 +1128,4 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
); );
}; };
export default Automation2FlowEditor; export default WorkflowFlowEditor;

View file

@ -1,11 +1,14 @@
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor'; // Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel'; export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas'; export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas'; export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel'; export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar'; export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem'; export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader'; export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils'; export * from './nodes/shared/utils';
export * from './nodes/shared/constants'; export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils'; export * from './nodes/shared/graphUtils';

View file

@ -0,0 +1,106 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One text field per option the text the end user sees in the dropdown.
* Stored as { value, label } with the same string so payload and UI stay in sync.
*/
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
export interface FormFieldOptionsEditorProps {
options: FormFieldOptionRow[];
onChange: (next: FormFieldOptionRow[]) => void;
className?: string;
rowClassName?: string;
}
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
options,
onChange,
className,
rowClassName,
}) => {
const { t } = useLanguage();
const rootClass = className ?? '';
const lineClass = rowClassName ?? '';
const setOptionText = (idx: number, text: string) => {
const next = options.map((o, i) =>
i === idx ? { value: text, label: text } : o,
);
onChange(next);
};
return (
<div className={rootClass}>
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
{t('Auswahloptionen')}
</div>
{options.map((opt, idx) => (
<div
key={idx}
className={lineClass}
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
<input
type="text"
placeholder={t('z.B. On hold')}
value={opt.label || opt.value}
onChange={(e) => setOptionText(idx, e.target.value)}
style={{
flex: '1 1 120px',
minWidth: 80,
padding: '4px 6px',
fontSize: '0.8rem',
borderRadius: 4,
border: '1px solid var(--border-color, #ddd)',
boxSizing: 'border-box',
}}
/>
<button
type="button"
title={t('Option entfernen')}
onClick={() => onChange(options.filter((_, i) => i !== idx))}
style={{
padding: '4px 8px',
border: 'none',
background: 'transparent',
color: 'var(--text-tertiary, #999)',
cursor: 'pointer',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
}}
>
<FaTimes />
</button>
</div>
))}
<button
type="button"
onClick={() => onChange([...options, { value: '', label: '' }])}
style={{
marginTop: 2,
padding: '4px 10px',
fontSize: '0.75rem',
borderRadius: 4,
border: '1px dashed var(--border-color, #bbb)',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-secondary, #555)',
cursor: 'pointer',
}}
>
+ {t('Option')}
</button>
</div>
);
};

View file

@ -1,46 +1,31 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Form node config - draggable fields, types, required toggle * Form node config - draggable fields, types, required toggle
*/ */
import React, { useEffect, useState } from 'react'; import React from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa'; import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../shared/types'; import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/WorkflowFlowEditor.module.css';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
updateParam,
instanceId,
request,
}) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useWorkflowDataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
const fields = (params.fields as FormField[]) ?? []; const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false);
useEffect(() => {
if (!instanceId || !request) {
setConnections([]);
return;
}
let cancelled = false;
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then((rows) => {
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
})
.catch(() => {
if (!cancelled) setConnections([]);
})
.finally(() => {
if (!cancelled) setConnectionsLoading(false);
});
return () => {
cancelled = true;
};
}, [instanceId, request]);
const moveField = (fromIndex: number, toIndex: number) => { const moveField = (fromIndex: number, toIndex: number) => {
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return; if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
@ -87,20 +72,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
</span> </span>
<div className={styles.formFieldInputs}> <div className={styles.formFieldInputs}>
<input <input
placeholder={t('name')} placeholder={t('Bezeichnung')}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], name: e.target.value };
updateParam('fields', next);
}}
/>
<input
placeholder={t('label')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const label = e.target.value;
const next = [...fields]; const next = [...fields];
next[i] = { ...next[i], label: e.target.value }; next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
updateParam('fields', next); updateParam('fields', next);
}} }}
/> />
@ -108,33 +85,22 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
</div> </div>
<div className={styles.formFieldRowFooter}> <div className={styles.formFieldRowFooter}>
<select <select
value={f.type ?? 'string'} value={f.type ?? 'text'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
const fieldType = e.target.value; const type = e.target.value as FormField['type'];
next[i] = { const row: FormField = { ...f, type };
...next[i], if (formFieldTypeHasConfigurableOptions(type)) {
type: fieldType, row.options = normalizeFormFieldOptions(row.options);
...(fieldType === 'clickup_tasks' }
? { clickupStatusOptions: undefined } next[i] = row;
: fieldType === 'clickup_status'
? { clickupConnectionId: undefined, clickupListId: undefined }
: {
clickupConnectionId: undefined,
clickupListId: undefined,
clickupStatusOptions: undefined,
}),
};
updateParam('fields', next); updateParam('fields', next);
}} }}
style={{ width: 'auto', minWidth: 90 }} style={{ width: 'auto', minWidth: 90 }}
> >
<option value="string">{t('Text')}</option> {fieldTypeOptions.map((ft) => (
<option value="number">{t('Zahl')}</option> <option key={ft.id} value={ft.id}>{t(ft.label)}</option>
<option value="date">{t('Datum')}</option> ))}
<option value="boolean">{t('Kontrollkästchen')}</option>
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
</select> </select>
<label className={styles.formFieldRequiredLabel}> <label className={styles.formFieldRequiredLabel}>
<input <input
@ -157,72 +123,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<FaTimes /> <FaTimes />
</button> </button>
</div> </div>
{f.type === 'clickup_status' ? ( {formFieldTypeHasConfigurableOptions(f.type) ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}> <FormFieldOptionsEditor
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? ( className={styles.formFieldOptionsBlock}
<p style={{ margin: '0 0 6px' }}> options={normalizeFormFieldOptions(f.options)}
{t( onChange={(opts) => {
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).', const next = [...fields];
{ count: String(f.clickupStatusOptions.length) } next[i] = { ...next[i], options: opts };
)} updateParam('fields', next);
</p> }}
) : ( />
<p style={{ margin: '0 0 6px' }}>
{t(
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
)}
</p>
)}
</div>
) : null}
{f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('ClickUp-Verbindung')}
</label>
<select
value={f.clickupConnectionId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupConnectionId: e.target.value };
updateParam('fields', next);
}}
disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }}
>
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
</label>
<input
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
value={f.clickupListId ?? ''}
onChange={(e) => {
const next = [...fields];
next[i] = { ...next[i], clickupListId: e.target.value };
updateParam('fields', next);
}}
style={{ width: '100%' }}
/>
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
</p>
</div>
) : null} ) : null}
</div> </div>
))} ))}
<button <button
type="button" type="button"
onClick={() => onClick={() =>
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }]) updateParam('fields', [
...fields,
{
name: deriveFormFieldPayloadKey('', fields.length),
type: 'text',
label: '',
required: false,
},
])
} }
> >
+ {t('Feld')} + {t('Feld')}

View file

@ -0,0 +1,42 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Helpers for optional select/multiselect rows on workflow form field definitions.
*/
export type FormFieldOptionRow = { value: string; label: string };
/** Field types where the author defines explicit { value, label } choices. */
export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
if (!typeId) return false;
return typeId === 'select' || typeId === 'enum';
}
export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
if (!Array.isArray(raw)) return [];
return raw.map((o, i) => {
if (o && typeof o === 'object' && !Array.isArray(o)) {
const r = o as Record<string, unknown>;
const value = String(r.value ?? r.id ?? '');
const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
return { value, label };
}
const s = String(o ?? '');
return { value: s, label: s };
});
}
/**
* Stable key for `payload.*` / data refs. From the visible label; empty label `field_<index>`.
*/
export function deriveFormFieldPayloadKey(label: string, index: number): string {
const trimmed = label.trim();
if (!trimmed) return `field_${index + 1}`;
const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
let s = deaccent
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
if (!s) return `field_${index + 1}`;
return s;
}

View file

@ -1 +1,10 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { FormNodeConfig } from './FormNodeConfig'; export { FormNodeConfig } from './FormNodeConfig';
export { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
export type { FormFieldOptionRow } from './formFieldOptionsUtils';
export {
deriveFormFieldPayloadKey,
formFieldTypeHasConfigurableOptions,
normalizeFormFieldOptions,
} from './formFieldOptionsUtils';

View file

@ -0,0 +1,276 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven case list for flow.switch (depends on value dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase {
operator: string;
value?: string | number | boolean;
}
function normalizeCase(c: unknown): SwitchCase {
if (c && typeof c === 'object' && 'operator' in (c as object)) {
const o = c as SwitchCase;
const v = o.value;
const safeValue: string | number | boolean | undefined =
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
return { operator: o.operator ?? 'eq', value: safeValue };
}
const fallbackValue: string | number | boolean | undefined =
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
return { operator: 'eq', value: fallbackValue };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
if (!operators.length) return cases;
return cases.map((c) => {
const op = operators.find((o) => o.id === c.operator) ?? operators[0];
return {
operator: op.id,
value: op.needsValue ? c.value ?? '' : undefined,
};
});
}
function CaseValueInput({
caseItem,
opDef,
valueKind,
onChange,
t,
}: {
caseItem: SwitchCase;
opDef: ConditionOperatorDef | undefined;
valueKind: string;
onChange: (v: string | number) => void;
t: (key: string) => string;
}) {
const valueInput = opDef?.valueInput;
const val = caseItem.value;
if (
valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime'
) {
return (
<select
value={String(val ?? '')}
onChange={(e) => onChange(e.target.value)}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
);
}
return (
<input
type={
valueInput?.kind === 'number' || valueKind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(val ?? '')}
onChange={(e) =>
onChange(
valueInput?.kind === 'number' || valueKind === 'number'
? parseFloat(e.target.value) || 0
: e.target.value
)
}
placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
);
}
export const CaseListEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
: 'value';
const valueParam = allParams?.[dependsOn];
const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
const rawCases = Array.isArray(value) ? value : [];
const cases: SwitchCase[] = rawCases.map(normalizeCase);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
const ops = operatorsFromCatalog(catalog, 'unknown');
setOperators(ops);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
if (cases.length > 0) {
const next = sanitizeCases(cases, ops);
if (JSON.stringify(next) !== JSON.stringify(cases)) {
onChange(next);
}
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => applyMeta(meta.valueKind, meta.operators))
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const setCases = (next: SwitchCase[]) => onChange(next);
const addCase = () => {
const opDef = operators[0];
setCases([
...cases,
{
operator: opDef?.id ?? 'eq',
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
},
]);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst einen Wert im Data Picker wählen')}
</p>
</div>
);
}
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{t('Lade Operatoren…')}
</div>
)}
{cases.map((c, i) => {
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
const needsValue = opDef?.needsValue ?? true;
return (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<select
value={c.operator}
onChange={(e) => {
const op = operators.find((o) => o.id === e.target.value);
const next = [...cases];
next[i] = {
operator: e.target.value,
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
};
setCases(next);
}}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
{needsValue && (
<CaseValueInput
caseItem={c}
opDef={opDef}
valueKind={valueKind}
t={t}
onChange={(v) => {
const next = [...cases];
next[i] = { ...next[i], value: v };
setCases(next);
}}
/>
)}
<button
type="button"
onClick={() => setCases(cases.filter((_, j) => j !== i))}
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
>
×
</button>
</div>
);
})}
<button
type="button"
onClick={addCase}
disabled={loading || operators.length === 0}
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
>
{t('Fall hinzufügen')}
</button>
</div>
);
};

View file

@ -0,0 +1,395 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* clickupList hierarchical ClickUp list picker via connector browse API.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { fetchBrowse, type BrowseEntry } from '../../../../api/workflowAutomationApi';
import { fetchClickupList } from '../../../../api/clickupApi';
import type { FieldRendererProps } from './index';
import {
clickupBrowseParentPath,
formatListPickerValue,
isClickupContainerEntry,
isClickupListEntry,
parseClickupListPath,
resolveListPathFromValue,
} from './clickupPathUtils';
const CLICKUP_PURPLE = '#7B68EE';
const glassPanel: React.CSSProperties = {
marginTop: 6,
borderRadius: 10,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255, 255, 255, 0.72)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
boxShadow:
'0 4px 24px rgba(123, 104, 238, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.5) inset',
padding: 8,
};
const glassTrigger: React.CSSProperties = {
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 8,
border: '1px solid rgba(123, 104, 238, 0.4)',
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(123,104,238,0.08) 100%)',
boxShadow: '0 0 12px rgba(123, 104, 238, 0.15)',
overflow: 'hidden',
};
export const ClickUpListPicker: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
instanceId,
request,
onPatchParams,
nodeType,
}) => {
const { t } = useLanguage();
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
const hasConnection = !!connectionReference && typeof connectionReference === 'string';
const [panelOpen, setPanelOpen] = useState(false);
const [browsePath, setBrowsePath] = useState('/');
const [items, setItems] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pickedLabel, setPickedLabel] = useState<string | null>(null);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const loadBrowse = useCallback(
async (path: string) => {
if (!request || !instanceId || !connectionReference) return;
setLoading(true);
setError(null);
try {
const res = await fetchBrowse(request, connectionReference, 'clickup', path);
setItems(res.items);
setBrowsePath(res.path || path);
} catch (err: unknown) {
setItems([]);
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
},
[request, instanceId, connectionReference],
);
useEffect(() => {
if (!panelOpen || !hasConnection) return;
void loadBrowse(browsePath);
}, [panelOpen, hasConnection, browsePath, loadBrowse]);
useEffect(() => {
if (!strVal) {
setPickedLabel(null);
return;
}
const pathFromVal = resolveListPathFromValue(strVal, param.name);
if (pathFromVal) {
const parsed = parseClickupListPath(pathFromVal);
if (parsed.listId && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, parsed.listId)
.then((data) => {
if (cancelled) return;
const name = typeof data.name === 'string' ? data.name : null;
setPickedLabel(name || parsed.listId || strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(parsed.listId || strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(parsed.listId || strVal);
return;
}
if (param.name === 'listId' && strVal && request && connectionReference) {
let cancelled = false;
fetchClickupList(request, connectionReference, strVal)
.then((data) => {
if (cancelled) return;
setPickedLabel(typeof data.name === 'string' ? data.name : strVal);
})
.catch(() => {
if (!cancelled) setPickedLabel(strVal);
});
return () => {
cancelled = true;
};
}
setPickedLabel(strVal);
}, [strVal, param.name, request, connectionReference]);
const shouldPatchTeamId =
nodeType === 'clickup.searchTasks' || Object.prototype.hasOwnProperty.call(allParams ?? {}, 'teamId');
const selectList = useCallback(
(entry: BrowseEntry) => {
const listPath = entry.path;
const stored = formatListPickerValue(listPath, param.name);
const { teamId, listId } = parseClickupListPath(listPath);
if (shouldPatchTeamId && onPatchParams && teamId) {
const patch: Record<string, unknown> = { [param.name]: stored };
patch.teamId = teamId;
onPatchParams(patch);
} else {
onChange(stored);
}
setPickedLabel(entry.name || listId || stored);
setPanelOpen(false);
},
[param.name, shouldPatchTeamId, onPatchParams, onChange],
);
const navigateInto = useCallback((entry: BrowseEntry) => {
if (!isClickupContainerEntry(entry.metadata, entry.isFolder)) return;
setBrowsePath(entry.path);
}, []);
const goUp = useCallback(() => {
setBrowsePath((p) => clickupBrowseParentPath(p));
}, []);
const clearSelection = useCallback(() => {
if (shouldPatchTeamId && onPatchParams) {
const patch: Record<string, unknown> = { [param.name]: '' };
if (nodeType === 'clickup.searchTasks') {
patch.teamId = '';
}
onPatchParams(patch);
} else {
onChange('');
}
setPickedLabel(null);
}, [shouldPatchTeamId, onPatchParams, onChange, param.name, nodeType]);
const triggerLabel = strVal
? pickedLabel ?? '…'
: t('ClickUp-Liste wählen');
const breadcrumb =
browsePath === '/'
? t('Workspaces')
: browsePath.replace(/^\/team\//, '').replace(/\//g, ' ');
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>
{param.description || param.name}
</label>
{!request || !instanceId ? (
<div style={{ fontSize: 11, color: '#888' }}>
{t('Listen-Browser nicht verfügbar (keine API-Anbindung).')}
</div>
) : (
<>
<div style={{ ...glassTrigger, opacity: hasConnection ? 1 : 0.55 }}>
<button
type="button"
disabled={!hasConnection}
onClick={() => {
if (!hasConnection) return;
setPanelOpen((o) => {
if (!o) setBrowsePath('/');
return !o;
});
}}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: hasConnection ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
color: 'var(--text-primary, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{hasConnection ? triggerLabel : t('Zuerst {field} wählen', { field: dependsOn })}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Auswahl aufheben')}
aria-label={t('Auswahl aufheben')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearSelection();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid rgba(123, 104, 238, 0.25)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && hasConnection && (
<div style={glassPanel}>
{error && (
<div style={{ fontSize: 11, color: '#c00', marginBottom: 6 }}>{error}</div>
)}
<div
style={{
display: 'flex',
gap: 6,
alignItems: 'center',
marginBottom: 6,
flexWrap: 'wrap',
}}
>
{browsePath !== '/' && (
<button
type="button"
onClick={goUp}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
{t('Hoch')}
</button>
)}
<button
type="button"
onClick={() => loadBrowse(browsePath)}
title={t('Neu laden')}
style={{
padding: '3px 8px',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.35)',
background: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
fontSize: 11,
}}
>
</button>
<span style={{ fontSize: 11, color: '#555', flex: 1, minWidth: 0 }}>
{breadcrumb}
</span>
</div>
<div
style={{
maxHeight: 220,
overflow: 'auto',
borderRadius: 6,
border: '1px solid rgba(123, 104, 238, 0.2)',
background: 'rgba(255, 255, 255, 0.85)',
}}
>
{loading && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>{t('Lade')}</div>
)}
{!loading && items.length === 0 && (
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>
{t('Keine Einträge')}
</div>
)}
{!loading &&
items.map((item) => {
const isList = isClickupListEntry(item.metadata);
const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder);
return (
<div
key={item.path}
style={{
display: 'flex',
alignItems: 'center',
padding: '4px 8px',
borderBottom: '1px solid rgba(123, 104, 238, 0.08)',
fontSize: 12,
}}
>
<span
role="button"
tabIndex={0}
onClick={() => {
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (isList) selectList(item);
else if (canNavigate) navigateInto(item);
}}
style={{
flex: 1,
cursor: isList || canNavigate ? 'pointer' : 'default',
userSelect: 'none',
}}
title={isList ? t('Liste wählen') : canNavigate ? t('Öffnen') : undefined}
>
{isList ? '📋' : canNavigate ? '📁' : '·'} {item.name}
</span>
{isList && (
<button
type="button"
onClick={() => selectList(item)}
style={{
padding: '2px 8px',
borderRadius: 6,
border: `1px solid ${CLICKUP_PURPLE}`,
background: CLICKUP_PURPLE,
color: '#fff',
cursor: 'pointer',
fontSize: 11,
boxShadow: '0 0 8px rgba(123, 104, 238, 0.45)',
}}
>
{t('Wählen')}
</button>
)}
</div>
);
})}
</div>
</div>
)}
</>
)}
</div>
);
};

View file

@ -0,0 +1,225 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/
import React from 'react';
import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition {
type: 'condition';
operator: string;
value?: string | number;
/** Legacy — ignored when Item is set */
ref?: DataRef | null;
}
function parseCondition(v: unknown): StructuredCondition {
if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
const c = v as StructuredCondition;
return { type: 'condition', operator: c.operator ?? 'eq', value: c.value };
}
return { type: 'condition', operator: 'eq', value: '' };
}
function operatorsFromCatalog(
catalog: Record<string, ConditionOperatorDef[]> | undefined,
valueKind: string
): ConditionOperatorDef[] {
if (!catalog) return [];
return catalog[valueKind] ?? catalog.unknown ?? [];
}
export const ConditionEditor: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
allParams,
}) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
: 'Item';
const itemRef = allParams?.[dependsOn];
const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
const cond = parseCondition(value);
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
const [valueKind, setValueKind] = React.useState('unknown');
const [loading, setLoading] = React.useState(false);
const catalog = dataFlow?.conditionOperatorCatalog;
React.useEffect(() => {
if (!ref) {
setOperators([]);
setValueKind('unknown');
return;
}
let cancelled = false;
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
if (cancelled) return;
setValueKind(vk);
setOperators(ops);
const valid = ops.some((o) => o.id === cond.operator);
if (!valid && ops.length > 0) {
const first = ops[0];
onChange({
type: 'condition',
operator: first.id,
value: first.needsValue ? cond.value ?? '' : undefined,
});
}
};
if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true);
fetchConditionMeta(dataFlow.request, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
})
.then((meta) => {
applyMeta(meta.valueKind, meta.operators);
})
.catch(() => {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
} else {
const ops = operatorsFromCatalog(catalog, 'unknown');
applyMeta('unknown', ops);
}
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
const needsValue = currentOp?.needsValue ?? true;
const valueInput = currentOp?.valueInput;
const setCondition = (next: StructuredCondition) => {
onChange(next);
};
if (!ref) {
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
{t('Zuerst ein Item im Data Picker wählen')}
</p>
</div>
);
}
const handleOperatorChange = (opId: string) => {
const opDef = operators.find((o) => o.id === opId);
setCondition({
type: 'condition',
operator: opId,
value: opDef?.needsValue ? cond.value ?? '' : undefined,
});
};
const handleValueChange = (v: string | number) => {
const kind = valueInput?.kind;
const parsed =
kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
setCondition({ type: 'condition', operator: cond.operator, value: parsed });
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
{param.description || param.name}
</label>
<ConditionRow>
<label>{t('Vergleich')}</label>
<select
value={cond.operator}
onChange={(e) => handleOperatorChange(e.target.value)}
disabled={loading || operators.length === 0}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{operators.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</ConditionRow>
{loading && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
)}
{needsValue && (
<ConditionRow>
<label>{t('Wert')}</label>
{valueInput?.kind === 'select' ||
valueInput?.kind === 'contentType' ||
valueInput?.kind === 'outputMode' ||
valueInput?.kind === 'language' ||
valueInput?.kind === 'mime' ? (
<select
value={String(cond.value ?? '')}
onChange={(e) => handleValueChange(e.target.value)}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('— wählen —')}</option>
{(valueInput.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : (
<input
type={
valueInput?.kind === 'number'
? 'number'
: valueInput?.kind === 'date'
? 'date'
: 'text'
}
value={String(cond.value ?? '')}
onChange={(e) =>
handleValueChange(
valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
)
}
placeholder={
valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
}
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
</ConditionRow>
)}
</div>
);
};
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
{children}
</div>
);

View file

@ -0,0 +1,374 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* One place to configure context.setContext rows: target key, then either
* upstream picker, a fixed literal, or a human task.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
type ValueSource = 'pickUpstream' | 'literal' | 'humanTask';
export interface ContextAssignmentRow {
contextKey: string;
valueSource: ValueSource;
/** Single resolved ref (server resolves { type: ref } to a value). */
upstreamRef?: DataRef | SystemVarRef | null;
/** Optional dotted path under the picked value, or under the wire payload (expert). */
sourcePath?: string;
literal?: string;
taskTitle?: string;
taskDescription?: string;
mode?: 'set' | 'setIfEmpty' | 'append' | 'increment';
valueType?: string;
}
function defaultRow(): ContextAssignmentRow {
return {
contextKey: '',
valueSource: 'literal',
literal: '',
mode: 'set',
valueType: 'str',
};
}
function legacyEntryToRow(
e: Record<string, unknown>,
globalPick: unknown,
): ContextAssignmentRow {
const am = String(e.assignmentMode || 'direct');
let valueSource: ValueSource = 'literal';
if (am === 'fromUpstream') valueSource = 'pickUpstream';
else if (am === 'humanTask') valueSource = 'humanTask';
const sourcePathStr = typeof e.sourcePath === 'string' ? e.sourcePath : '';
let upstream: DataRef | SystemVarRef | undefined;
if (isRef(e.upstreamRef) || isSystemVar(e.upstreamRef)) {
upstream = e.upstreamRef as DataRef | SystemVarRef;
} else if (
am === 'fromUpstream' &&
!sourcePathStr.trim() &&
(isRef(globalPick) || isSystemVar(globalPick))
) {
upstream = globalPick as DataRef | SystemVarRef;
}
return {
contextKey: typeof e.contextKey === 'string' ? e.contextKey : typeof e.key === 'string' ? e.key : '',
valueSource,
upstreamRef: upstream,
sourcePath: sourcePathStr,
literal: e.literal != null ? String(e.literal) : e.value != null ? String(e.value) : '',
taskTitle: typeof e.taskTitle === 'string' ? e.taskTitle : '',
taskDescription: typeof e.taskDescription === 'string' ? e.taskDescription : '',
mode: (e.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof e.valueType === 'string' ? e.valueType : typeof e.type === 'string' ? e.type : 'str',
};
}
function normalizeRows(raw: unknown, allParams?: Record<string, unknown>): ContextAssignmentRow[] {
if (Array.isArray(raw) && raw.length > 0) {
return raw.map((r) => {
if (!r || typeof r !== 'object') return defaultRow();
const o = r as Record<string, unknown>;
let valueSource = o.valueSource as ValueSource | undefined;
if (!valueSource && o.assignmentMode === 'fromUpstream') valueSource = 'pickUpstream';
else if (!valueSource && o.assignmentMode === 'humanTask') valueSource = 'humanTask';
else if (!valueSource) valueSource = 'literal';
return {
contextKey: typeof o.contextKey === 'string' ? o.contextKey : typeof o.key === 'string' ? o.key : '',
valueSource,
upstreamRef: (isRef(o.upstreamRef) || isSystemVar(o.upstreamRef) ? o.upstreamRef : undefined) as
| DataRef
| SystemVarRef
| undefined,
sourcePath: typeof o.sourcePath === 'string' ? o.sourcePath : '',
literal: o.literal != null ? String(o.literal) : o.value != null ? String(o.value) : '',
taskTitle: typeof o.taskTitle === 'string' ? o.taskTitle : '',
taskDescription: typeof o.taskDescription === 'string' ? o.taskDescription : '',
mode: (o.mode as ContextAssignmentRow['mode']) || 'set',
valueType: typeof o.valueType === 'string' ? o.valueType : typeof o.type === 'string' ? o.type : 'str',
};
});
}
const g = allParams;
if (g && Array.isArray(g.entries) && g.entries.length > 0) {
const globalPick = g.upstreamPick;
return (g.entries as Record<string, unknown>[]).map((e) => legacyEntryToRow(e, globalPick));
}
if (g) {
const tk = String(g.targetKey || '').trim();
const globalPick = g.upstreamPick;
if (
tk &&
globalPick !== undefined &&
globalPick !== null &&
!(typeof globalPick === 'string' && !globalPick.trim()) &&
!(typeof globalPick === 'object' && globalPick !== null && Object.keys(globalPick).length === 0)
) {
const ups =
isRef(globalPick) || isSystemVar(globalPick) ? (globalPick as DataRef | SystemVarRef) : undefined;
return [
{
contextKey: tk,
valueSource: 'pickUpstream' as const,
upstreamRef: ups,
sourcePath: '',
literal: '',
taskTitle: '',
taskDescription: '',
mode: 'set',
valueType: 'str',
},
];
}
}
return [defaultRow()];
}
const MODES: Array<{ id: NonNullable<ContextAssignmentRow['mode']>; labelDe: string }> = [
{ id: 'set', labelDe: 'setzen' },
{ id: 'setIfEmpty', labelDe: 'setzen wenn leer' },
{ id: 'append', labelDe: 'anhängen' },
{ id: 'increment', labelDe: 'addieren' },
];
const TYPES = ['str', 'int', 'float', 'bool', 'object', 'list'] as const;
const ROW_BOX: React.CSSProperties = {
border: '1px solid #ddd',
borderRadius: 6,
padding: 8,
marginBottom: 8,
background: '#fafafa',
};
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 8px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginTop: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 6px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const setRows = (next: ContextAssignmentRow[]) => {
onChange(next.length ? next : [defaultRow()]);
};
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
const next = [...rows];
next[idx] = { ...next[idx], ...patch };
setRows(next);
};
const addRow = () => setRows([...rows, defaultRow()]);
const removeRow = (idx: number) => {
if (rows.length <= 1) {
onChange([defaultRow()]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
const labelForRef = (ref: DataRef | SystemVarRef): string => {
if (isSystemVar(ref)) {
return t('System') + `: ${ref.variable}`;
}
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
if (!isRef(picked) && !isSystemVar(picked)) return;
setRow(idx, { upstreamRef: picked });
setPickerRow(null);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{rows.map((row, idx) => (
<div key={idx} style={ROW_BOX}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="text"
placeholder={t('Ziel-Schlüssel im Kontext')}
value={row.contextKey}
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<select
value={row.valueSource}
onChange={(e) => {
const vs = e.target.value as ValueSource;
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
if (vs === 'literal') patch.upstreamRef = undefined;
if (vs === 'pickUpstream') patch.literal = '';
setRow(idx, patch);
}}
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
<option value="literal">{t('Fester Wert')}</option>
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
</select>
<select
value={row.mode || 'set'}
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{MODES.map((m) => (
<option key={m.id} value={m.id}>
{m.labelDe}
</option>
))}
</select>
<select
value={row.valueType || 'str'}
onChange={(e) => setRow(idx, { valueType: e.target.value })}
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{tp}
</option>
))}
</select>
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
×
</button>
</div>
{row.valueSource === 'pickUpstream' && (
<div>
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
<div style={CHIP_STYLE}>
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
×
</button>
</div>
)}
<button
type="button"
onClick={() => setPickerRow(idx)}
disabled={!hasSources}
style={{
marginTop: 4,
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #1c5fb5',
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
<input
type="text"
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
value={row.sourcePath || ''}
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
/>
</div>
)}
{row.valueSource === 'literal' && (
<input
type="text"
placeholder={t('Wert (oder JSON für object/list)')}
value={row.literal ?? ''}
onChange={(e) => setRow(idx, { literal: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
{row.valueSource === 'humanTask' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text"
placeholder={t('Titel der Aufgabe (optional)')}
value={row.taskTitle || ''}
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<textarea
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
value={row.taskDescription || ''}
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
rows={2}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
/>
</div>
)}
</div>
))}
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
{t('Zuweisung hinzufügen')}
</button>
{dataFlow && pickerRow != null && (
<DataPicker
open
onClose={() => setPickerRow(null)}
onPick={(picked) => onPickRef(pickerRow, picked)}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType="Any"
/>
)}
</div>
);
};

View file

@ -0,0 +1,183 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* ContextBuilderRenderer multi-select context binding for AI nodes.
*
* Renders a list of DataRef entries (each pointing to an upstream node's output
* path). On execution the backend serialises each ref, joins them with double
* newlines and prepends the result to the AI prompt.
*
* Stored value shape:
* [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, ]
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
function isRefEntry(v: unknown): v is DataRef {
return isRef(v);
}
function toRefList(raw: unknown): DataRef[] {
if (!raw) return [];
if (Array.isArray(raw)) return raw.filter(isRefEntry);
if (isRefEntry(raw)) return [raw];
return [];
}
const CHIP_STYLE: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '3px 6px 3px 10px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginBottom: 4,
};
const REMOVE_BTN: React.CSSProperties = {
padding: '0 5px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
marginLeft: 'auto',
};
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null);
const entries = toRefList(value);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const getRefLabel = (ref: DataRef): string => {
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const addRef = (picked: DataRef | SystemVarRef) => {
if (!isRefEntry(picked)) return;
const alreadyIn = entries.some(
(e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'),
);
if (!alreadyIn) {
onChange([...entries, picked]);
}
setPickerOpen(false);
};
const removeRef = (index: number) => {
const next = entries.filter((_, i) => i !== index);
onChange(next.length ? next : undefined);
};
const moveRef = (fromIndex: number, toIndex: number) => {
if (fromIndex === toIndex) return;
const next = [...entries];
const [moved] = next.splice(fromIndex, 1);
next.splice(toIndex, 0, moved);
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{entries.length > 0 && (
<div style={{ marginBottom: 4 }}>
{entries.map((ref, i) => (
<div
key={`${ref.nodeId}-${ref.path.join('.')}`}
style={{ ...CHIP_STYLE, cursor: 'grab' }}
draggable
onDragStart={() => { dragIndex.current = i; }}
onDragOver={(e) => { e.preventDefault(); }}
onDrop={() => {
if (dragIndex.current != null) moveRef(dragIndex.current, i);
dragIndex.current = null;
}}
onDragEnd={() => { dragIndex.current = null; }}
>
<span style={{ flex: 1, color: '#2d6a2d' }}>
{getRefLabel(ref)}
</span>
<button type="button" style={REMOVE_BTN} onClick={() => removeRef(i)} title={t('Entfernen')}>
×
</button>
</div>
))}
</div>
)}
{entries.length === 0 && (
<div
style={{
padding: '4px 8px',
background: '#f8f8f8',
border: '1px dashed #ccc',
borderRadius: 4,
fontSize: 11,
color: '#888',
marginBottom: 4,
}}
>
{t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')}
</div>
)}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={!hasSources}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: `1px solid #1c5fb5`,
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('+ Datenquelle hinzufügen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={addRef}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

Some files were not shown because too many files have changed in this diff Show more