Compare commits

..

24 commits

Author SHA1 Message Date
Ida
60ff00802c continued testing and improvement 2026-05-14 19:25:44 +02:00
Ida
b5084c028e fix: handover fix, if/else node extended comparison mode 2026-05-14 18:38:44 +02:00
Ida
587dad5cf9 feat: extract content node angepasst für mehr optionen 2026-05-14 13:06:31 +02:00
Ida
0fd05f638f feat: seperated accordion list component to be seperate and reusable ui component 2026-05-14 12:08:05 +02:00
Ida
aa61e00af6 fix: moved cron schedule calculator to utils for better reusability 2026-05-14 11:57:45 +02:00
Ida
7e2ffb42fe fix: schedule node to be more user friendly 2026-05-14 11:52:17 +02:00
Ida
dd26ea132d fix: formular trigger 2026-05-14 11:15:16 +02:00
Ida
50a3df5c18 feat: added trigger nodes such that they are not hidden anymore 2026-05-14 10:57:29 +02:00
Ida
e7f2272c30 fix: formular node aufgeräumt und files besser sortiert 2026-05-13 16:58:02 +02:00
Ida
ef9955257e fix: pfeile zeichnen nicht mehr verbugged 2026-05-13 16:46:10 +02:00
Ida
6890a38546 fix: arrow beginning und ending 2026-05-13 16:41:03 +02:00
Ida
590178b8f2 feat: ctrl c shortcut und pfeil zeichnen 2026-05-13 16:24:38 +02:00
Ida
e3c93dc220 fix: anordnen knopf wieder hinzugefügt mit verschachtelten rangpfaden 2026-05-13 16:16:41 +02:00
Ida
600e0c87dc fix: leerer select in node im config panel leading to white screen 2026-05-13 15:48:30 +02:00
Ida
9e36075f0e fix: resized canvas size and fixed delete comments 2026-05-13 15:42:00 +02:00
Ida
3a7a34a4f3 feat: added edit button bar und kommentar-funktion 2026-05-13 15:36:16 +02:00
Ida
c13489e232 fix: removed legacy code 2026-05-13 15:03:37 +02:00
Ida
4bf6677bc5 fix: clean header 2026-05-13 15:00:06 +02:00
Ida
8860f49714 fix: removed duplicate keepAlive Files for pages and views and instead implemented single source of truth for mounted pages, removed unused legacy core page manager 2026-05-13 14:25:20 +02:00
Ida
74dc7b85f8 continous work on grafischer editor, loop verbessert 2026-05-13 13:30:45 +02:00
Ida
66a7a6fa56 neue context nodes hinzugefügt, muss noch debuggt werden 2026-05-12 06:34:32 +02:00
Ida
294803e66c node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick 2026-05-12 06:34:32 +02:00
Ida
ae630201ba finished file tree folder selection in file create node 2026-05-12 06:34:30 +02:00
Ida
7fb96451a5 workign on folder location in file create node 2026-05-12 06:34:10 +02:00
501 changed files with 26695 additions and 13420 deletions

View file

@ -0,0 +1,144 @@
# 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

@ -1,53 +0,0 @@
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

@ -1,53 +0,0 @@
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

71
.github/workflows/poweron_nyla_int.yml vendored Normal file
View file

@ -0,0 +1,71 @@
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-poweron-nyla-int.env .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

71
.github/workflows/poweron_nyla_main.yml vendored Normal file
View file

@ -0,0 +1,71 @@
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-poweron-nyla-prod.env .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

5
.gitignore vendored
View file

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

View file

@ -1,24 +1,178 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Configuration reads mandatory env vars set by .env (copied from config/env-*.env by CI). * Simple Configuration Service
* * 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.
*/ */
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL; // API Configuration
export const getApiBaseUrl = (): string => {
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
};
if (!_apiBaseUrl) { export const getApiTimeout = (): number => {
throw new Error( return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).' };
);
}
export const getApiBaseUrl = (): string => _apiBaseUrl; // App Configuration
export const getAppName = (): string => {
return import.meta.env.VITE_APP_NAME || 'PowerOn';
};
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn'; export const getAppVersion = (): string => {
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,
};

View file

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

View file

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

View file

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

View file

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

2
env.d.ts vendored
View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {

View file

@ -185,10 +185,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company &amp; legal details &middot; May 2026</p> <p>&copy; 2025 PowerOn AI Platform. All rights reserved.</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> May 2026 <strong>Last Updated:</strong> August 2025
</div> </div>
<div class="content-section"> <div class="content-section">
@ -272,13 +272,8 @@
<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> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p> <p><strong>Email:</strong> privacy@poweron-ai.com</p>
<p><strong>Address:</strong><br> <p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -288,7 +283,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2026 PowerOn. All rights reserved.</p> <p>&copy; 2025 PowerOn AI Platform. 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> May 2026 <strong>Last Updated:</strong> August 2025
</div> </div>
<div class="content-section"> <div class="content-section">
@ -315,13 +315,8 @@
<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> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p> <p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong><br> <p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
</div> </div>
</div> </div>
@ -331,7 +326,7 @@
</div> </div>
<div class="footer"> <div class="footer">
<p>&copy; 2026 PowerOn. All rights reserved.</p> <p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div> </div>
</div> </div>
</body> </body>

15027
scripts/i18n_missing_report.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* App.tsx * App.tsx
* *
@ -41,12 +39,11 @@ 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 } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } 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 { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage'; import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
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
@ -126,20 +123,15 @@ function App() {
</Route> </Route>
{/* ============================================== */} {/* ============================================== */}
{/* WORKFLOW AUTOMATION (System-Komponente) */} {/* AUTOMATIONS DASHBOARD */}
{/* ============================================== */} {/* ============================================== */}
<Route path="workflow-automation" element={<WorkflowAutomationPage />} /> <Route path="automations" element={<AutomationsDashboardPage />} />
{/* ============================================== */}
{/* 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 */}
@ -173,8 +165,13 @@ 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 Editor */} {/* Workspace + Automation2 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: legacy workflows URL → editor */}
<Route path="workflows" element={<Navigate to="../editor" replace />} />
<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" />} />
@ -221,7 +218,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={null} /> <Route path="database-health" element={<AdminDatabaseHealthPage />} />
<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 />} />

View file

@ -1,10 +1,25 @@
// 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/...
@ -29,25 +44,52 @@ 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: _baseUrl, baseURL: getApiBaseUrl(),
withCredentials: true, withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null }, paramsSerializer: { indexes: null },
}); });
// Add a request interceptor to add the auth token, context headers // Add a request interceptor to add the auth token, context headers, and log backend IP
api.interceptors.request.use( api.interceptors.request.use(
async (config) => { async (config) => {
// Add auth token if available (otherwise httpOnly cookies are used automatically) // Log backend information
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

View file

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

View file

@ -1,5 +1,3 @@
// 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';
@ -14,30 +12,14 @@ export interface LoginRequest {
} }
export interface LoginResponse { export interface LoginResponse {
type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required'; type: 'local_auth_success';
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;
@ -334,36 +316,3 @@ 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,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================

329
src/api/chatbotApi.ts Normal file
View file

@ -0,0 +1,329 @@
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;
}
}

View file

@ -1,135 +0,0 @@
// 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,5 +1,3 @@
// 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';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -8,11 +6,17 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface KnowledgePreferences { export interface KnowledgePreferences {
schemaVersion?: number; schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full'; mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean; mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean; filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments'; clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean; clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number; maxAgeDays?: number;
} }
@ -288,210 +292,3 @@ export async function submitInfomaniakToken(
}); });
} }
// ============================================================================
// 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,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Features API * Features API
* *
@ -16,6 +14,8 @@ 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,11 +172,56 @@ 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);
@ -194,6 +239,7 @@ 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,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { ApiRequestOptions } from '../hooks/useApi'; import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================ // ============================================================================
@ -38,7 +36,6 @@ export interface PaginationParams {
search?: string; search?: string;
viewKey?: string; viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>; groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
owner?: 'all' | 'me' | 'shared';
} }
export interface PaginatedResponse<T> { export interface PaginatedResponse<T> {
@ -112,7 +109,6 @@ export async function fetchFiles(
if (params.search) paginationObj.search = params.search; if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey; if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels; 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);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
// 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';

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import api from '../api'; import api from '../api';
export interface TableListViewRow { export interface TableListViewRow {

View file

@ -1,5 +1,3 @@
// 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';
@ -73,7 +71,6 @@ export interface TeamsbotConfig {
triggerCooldownSeconds: number; triggerCooldownSeconds: number;
contextWindowSegments: number; contextWindowSegments: number;
debugMode?: boolean; debugMode?: boolean;
avatarFileId?: string;
} }
export interface TeamsbotSessionStats { export interface TeamsbotSessionStats {
@ -87,7 +84,6 @@ 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;
@ -106,7 +102,6 @@ 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
@ -467,13 +462,6 @@ 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)
// ========================================================================= // =========================================================================
@ -604,9 +592,6 @@ export interface MeetingModule {
defaultDirectorPrompts?: string; defaultDirectorPrompts?: string;
goals?: string; goals?: string;
kpiTargets?: string; kpiTargets?: string;
defaultMeetingLink?: string;
defaultBotName?: string;
defaultAvatarFileId?: string;
status: string; status: string;
} }
@ -617,7 +602,6 @@ export async function listModules(instanceId: string): Promise<MeetingModule[]>
export async function createModule(instanceId: string, body: { export async function createModule(instanceId: string, body: {
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string; title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
}): Promise<MeetingModule> { }): Promise<MeetingModule> {
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body); const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
return response.data?.module; return response.data?.module;
@ -636,31 +620,3 @@ export async function updateModule(instanceId: string, moduleId: string, body: P
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> { export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`); 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,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Trustee API * Trustee API
* *
@ -866,14 +864,7 @@ export async function syncPositionsToAccounting(
request: ApiRequestFunction, request: ApiRequestFunction,
instanceId: string, instanceId: string,
positionIds: string[], positionIds: string[],
opts?: { opts?: { pollMs?: number; onProgress?: (progress: number, message?: string | null) => void }
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[] }> { ): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
const submission = await request({ const submission = await request({
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`, url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -73,12 +73,13 @@
/* Connector grid (Step 0) */ /* Connector grid (Step 0) */
.connectorGrid { .connectorGrid {
display: grid; display: flex;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
.connectorCard { .connectorCard {
flex: 1 1 140px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@ -446,22 +447,6 @@
cursor: not-allowed; 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 */ /* Dark theme */
:global(.dark-theme) .connectorCard { :global(.dark-theme) .connectorCard {
background: var(--surface-color); background: var(--surface-color);

View file

@ -1,55 +1,153 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* AddConnectionWizard * AddConnectionWizard
* *
* Streamlined multi-step modal for adding a new connector. * Multi-step modal for adding a new connector with optional knowledge
* Steps are connector-type-aware: * ingestion consent and per-connection preferences (§2.6).
* Base: Connector Consent Connect *
* Microsoft: Connector Consent Admin Consent (optional) Connect * Steps:
* Infomaniak: Connector Consent PAT Input (done) * 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal'; import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa'; import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext'; import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css'; import styles from './AddConnectionWizard.module.css';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak'; export type ConnectorType = 'google' | 'msft' | 'clickup';
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
interface WizardState { interface WizardState {
currentStep: StepId; step: 0 | 1 | 2 | 3;
connector: ConnectorType | null; connector: ConnectorType | null;
knowledgeEnabled: boolean; knowledgeEnabled: boolean;
infomaniakToken: string; prefs: KnowledgePreferences;
adminConsentDone: boolean;
} }
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = { const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google', google: 'Google',
msft: 'Microsoft 365', msft: 'Microsoft 365',
clickup: 'ClickUp', clickup: 'ClickUp',
infomaniak: 'Infomaniak',
}; };
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = { const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />, google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />, msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />, clickup: <FaTasks style={{ color: '#7b68ee' }} />,
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
}; };
function _getSteps(connector: ConnectorType | null): StepId[] { // ---------------------------------------------------------------------------
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect']; // Cost estimate helper
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat']; // ---------------------------------------------------------------------------
return ['connector', 'consent', 'connect'];
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -59,9 +157,11 @@ function _getSteps(connector: ConnectorType | null): StepId[] {
interface AddConnectionWizardProps { interface AddConnectionWizardProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>; onConnect: (
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>; type: ConnectorType,
onMsftAdminConsent?: () => void; knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean; isConnecting?: boolean;
} }
@ -73,93 +173,84 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open, open,
onClose, onClose,
onConnect, onConnect,
onInfomaniakConnect,
onMsftAdminConsent,
isConnecting = false, isConnecting = false,
}) => { }) => {
const { t } = useLanguage();
const [state, setState] = useState<WizardState>({ const [state, setState] = useState<WizardState>({
currentStep: 'connector', step: 0,
connector: null, connector: null,
knowledgeEnabled: false, knowledgeEnabled: false,
infomaniakToken: '', prefs: { ...DEFAULT_PREFS },
adminConsentDone: false,
}); });
const reset = () => const reset = () =>
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false }); setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
const handleClose = () => { reset(); onClose(); }; const handleClose = () => {
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(); reset();
onClose(); onClose();
}; };
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
reset();
onClose();
};
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return ( return (
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape> <Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */} {/* Stepper */}
<div className={styles.stepper}> <div className={styles.stepper}>
{steps.map((s, i) => ( {[0, 1, 2, 3].map(i => (
<div <div
key={s} key={i}
className={[ className={[
styles.stepDot, styles.stepDot,
stepIndex === i ? styles.stepDotActive : '', state.step === i ? styles.stepDotActive : '',
stepIndex > i ? styles.stepDotDone : '', state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')} ].join(' ')}
> >
{stepIndex > i ? <FaCheck size={10} /> : i + 1} {state.step > i ? <FaCheck size={10} /> : i + 1}
</div> </div>
))} ))}
</div> </div>
<div className={styles.body}> <div className={styles.body}>
{/* ---- Step: Connector ---- */} {/* ---- Step 0: Connector ---- */}
{state.currentStep === 'connector' && ( {state.step === 0 && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3> <h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p> <p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}> <div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => ( {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
<button <button
key={type} key={type}
type="button" type="button"
className={styles.connectorCard} className={styles.connectorCard}
onClick={() => selectConnector(type)} onClick={() => setConnector(type)}
> >
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span> <span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span> <span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
@ -169,119 +260,253 @@ export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
</div> </div>
)} )}
{/* ---- Step: Consent ---- */} {/* ---- Step 1: Consent ---- */}
{state.currentStep === 'consent' && ( {state.step === 1 && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3> <div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}> <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') })} Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p> </p>
<p className={styles.stepHint}> <p className={styles.stepHint}>
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')} Du kannst diese Einstellung später in den Verbindungsdetails ändern.
</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> </p>
<div className={styles.consentButtons}> <div className={styles.consentButtons}>
<button <button
type="button" type="button"
className={styles.consentButtonYes} className={styles.consentButtonYes}
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }} onClick={() => setKnowledgeEnabled(true)}
> >
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')} <FaCheck /> Ja, aufnehmen
</button> </button>
<button type="button" className={styles.consentButtonNo} onClick={goNext}> <button
{t('Überspringen')} type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button> </button>
</div> </div>
<div className={styles.stepNavLeft}> <div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button> <button type="button" className={styles.navBack} onClick={() => setStep(0)}>
</div> Zurück
</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> </button>
</div> </div>
</div> </div>
)} )}
{/* ---- Step: Connect ---- */} {/* ---- Step 2: Preferences ---- */}
{state.currentStep === 'connect' && ( {state.step === 2 && (
<div className={styles.stepContent}> <div className={styles.stepContent}>
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3> <h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<div className={styles.summary}> <div className={styles.summary}>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Anbieter')}</span> <span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.connector && CONNECTOR_ICONS[state.connector]}&nbsp; {CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'} {state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span> </span>
</div> </div>
<div className={styles.summaryRow}> <div className={styles.summaryRow}>
<span className={styles.summaryKey}>{t('Wissensdatenbank')}</span> <span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryVal}> <span className={styles.summaryVal}>
{state.knowledgeEnabled ? t('Aktiv') : t('Nicht aktiv')} {state.knowledgeEnabled ? 'Aktiv' : 'Nicht aktiv'}
</span> </span>
</div> </div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div> </div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}> <div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button> <button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button <button
type="button" type="button"
className={styles.navConnect} className={styles.navConnect}
onClick={handleFinalConnect} onClick={handleConnect}
disabled={isConnecting} disabled={isConnecting}
> >
{isConnecting ? t('Verbinden…') : t('Mit {provider} verbinden', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : '…' })} {isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
{!isConnecting && <FaArrowRight size={12} />} {!isConnecting && <FaArrowRight size={12} />}
</button> </button>
</div> </div>

View file

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

View file

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

View file

@ -1,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,5 +1,3 @@
// 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,16 +1,14 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Workflow Flow Editor - Data flow context for Data Picker and DynamicValueField. * Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
* Extended with portTypeCatalog and systemVariables for the Typed Port System. * Extended with portTypeCatalog and systemVariables for the Typed Port System.
*/ */
import React, { createContext, useContext, useMemo } from 'react'; import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas'; import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph'; import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowAutomationApi'; import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface WorkflowDataFlowContextValue { export interface Automation2DataFlowContextValue {
currentNodeId: string; currentNodeId: string;
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -32,13 +30,13 @@ export interface WorkflowDataFlowContextValue {
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null; parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
} }
const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null); const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null { export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
return useContext(WorkflowDataFlowContext); return useContext(Automation2DataFlowContext);
} }
interface WorkflowDataFlowProviderProps { interface Automation2DataFlowProviderProps {
node: CanvasNode | null; node: CanvasNode | null;
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -54,7 +52,7 @@ interface WorkflowDataFlowProviderProps {
children: React.ReactNode; children: React.ReactNode;
} }
export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
node, node,
nodes, nodes,
connections, connections,
@ -69,7 +67,7 @@ export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> =
request, request,
children, children,
}) => { }) => {
const value = useMemo((): WorkflowDataFlowContextValue | null => { const value = useMemo((): Automation2DataFlowContextValue | null => {
if (!node) return null; if (!node) return null;
const formTypeToPort: Record<string, string> = Object.fromEntries( const formTypeToPort: Record<string, string> = Object.fromEntries(
formFieldTypes.map((f) => [f.id, f.portType]) formFieldTypes.map((f) => [f.id, f.portType])
@ -137,8 +135,8 @@ export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> =
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]); }, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
return ( return (
<WorkflowDataFlowContext.Provider value={value}> <Automation2DataFlowContext.Provider value={value}>
{children} {children}
</WorkflowDataFlowContext.Provider> </Automation2DataFlowContext.Provider>
); );
}; };

View file

@ -1,5 +1,5 @@
/** /**
* Workflow Flow Editor Styles * Automation2 Flow Editor Styles
* Sidebar with node list + canvas area. * Sidebar with node list + canvas area.
*/ */
@ -255,7 +255,6 @@
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 { .canvasHeaderToolbar {
@ -389,8 +388,8 @@
/* Closed <select> width must not follow the longest option label. */ /* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect { .canvasHeaderWorkflowSelect {
flex: 0 1 12.5rem; flex: 0 0 auto;
min-width: 8rem; width: 12.5rem;
max-width: 100%; max-width: 100%;
padding: 0.31rem 0.45rem; padding: 0.31rem 0.45rem;
min-height: 30px; min-height: 30px;
@ -402,61 +401,11 @@
color: var(--text-primary, #333); color: var(--text-primary, #333);
} }
.canvasHeaderTitleBlock { .canvasHeaderIconBtn {
flex: 1 1 auto; padding: 6px !important;
min-width: 0; min-width: 30px !important;
display: flex; min-height: 30px !important;
align-items: center; box-sizing: border-box !important;
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) { .canvasHeaderSplitPair :global(.button + .button) {

View file

@ -1,7 +1,5 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* WorkflowFlowEditor * Automation2FlowEditor
* *
* n8n-style flow builder with backend-driven node list and categories. * n8n-style flow builder with backend-driven node list and categories.
* Start nodes come from the API (category `start`); invocations are synced on the server from the graph. * Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
@ -25,16 +23,15 @@ import {
createTemplateFromWorkflow, createTemplateFromWorkflow,
copyTemplate, copyTemplate,
importWorkflowFromFile, importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
type NodeType, type NodeType,
type NodeTypeCategory, type NodeTypeCategory,
type WorkflowGraph, type Automation2Graph,
type WorkflowDefinition, type Automation2Workflow,
type ExecuteGraphResponse, type ExecuteGraphResponse,
type WorkflowEntryPoint, type WorkflowEntryPoint,
type AutoVersion, type AutoVersion,
type AutoTemplateScope, type AutoTemplateScope,
} from '../../../api/workflowAutomationApi'; } from '../../../api/workflowApi';
import { import {
FlowCanvas, FlowCanvas,
type CanvasNode, type CanvasNode,
@ -52,7 +49,7 @@ import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsFo
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry'; import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation'; import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils'; import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext'; import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
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';
@ -60,12 +57,12 @@ 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 './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[WorkflowEditor]'; const LOG = '[Automation2]';
const CANVAS_HISTORY_MAX = 50; const CANVAS_HISTORY_MAX = 50;
@ -81,7 +78,7 @@ function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[
}; };
} }
interface WorkflowFlowEditorProps { interface Automation2FlowEditorProps {
instanceId: string; instanceId: string;
mandateId?: string; mandateId?: string;
language?: string; language?: string;
@ -95,7 +92,7 @@ interface WorkflowFlowEditorProps {
onSourcesChanged?: () => void; onSourcesChanged?: () => void;
} }
export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId, export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
mandateId, mandateId,
language = 'de', language = 'de',
initialWorkflowId, initialWorkflowId,
@ -113,9 +110,9 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
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 [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState< const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
Record<string, import('../../../api/workflowAutomationApi').ConditionOperatorDef[]> Record<string, import('../../../api/workflowApi').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);
@ -140,7 +137,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]); 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<WorkflowDefinition[]>([]); const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
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);
@ -156,12 +153,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
instanceId, instanceId,
mandateId: mandateId || '', mandateId: mandateId || '',
featureInstanceId: instanceId, featureInstanceId: instanceId,
surface: 'workflowAutomation', surface: 'graphEditor',
}), [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 [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
@ -304,7 +300,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const applyGraphWithSync = useCallback( const applyGraphWithSync = useCallback(
( (
graph: WorkflowGraph | null | undefined, graph: Automation2Graph | null | undefined,
wfInvocations: WorkflowEntryPoint[] | undefined, wfInvocations: WorkflowEntryPoint[] | undefined,
opts?: { skipHistory?: boolean } opts?: { skipHistory?: boolean }
) => { ) => {
@ -312,7 +308,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
pushCanvasHistoryPastFromCurrent(); pushCanvasHistoryPastFromCurrent();
} }
setInvocations(wfInvocations ?? []); setInvocations(wfInvocations ?? []);
const g: WorkflowGraph = graph ?? { nodes: [], connections: [] }; const g: Automation2Graph = graph ?? { nodes: [], connections: [] };
const { nodes, connections } = fromApiGraph(g, nodeTypes); const { nodes, connections } = fromApiGraph(g, nodeTypes);
setCanvasNodes(nodes); setCanvasNodes(nodes);
setCanvasConnections(connections); setCanvasConnections(connections);
@ -321,7 +317,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
); );
const handleFromApiGraph = useCallback( const handleFromApiGraph = useCallback(
(graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => { (graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations); applyGraphWithSync(graph, wfInvocations);
}, },
[applyGraphWithSync] [applyGraphWithSync]
@ -357,7 +353,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
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, graph, currentWorkflowId ?? undefined, { const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}), ...(ep ? { entryPointId: ep } : {}),
}); });
setExecuteResult(result); setExecuteResult(result);
@ -406,7 +402,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setSaving(true); setSaving(true);
try { try {
if (currentWorkflowId) { if (currentWorkflowId) {
const updated = await updateWorkflow(request, currentWorkflowId, { const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
graph, graph,
invocations, invocations,
targetFeatureInstanceId, targetFeatureInstanceId,
@ -423,12 +419,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setSaving(false); setSaving(false);
return; return;
} }
const created = await createWorkflow(request, { const created = await createWorkflow(request, instanceId, {
label: label.trim() || t('Neuer Workflow'), label: label.trim() || t('Neuer Workflow'),
graph, graph,
invocations, invocations,
targetFeatureInstanceId, targetFeatureInstanceId,
mandateId,
}); });
setCurrentWorkflowId(created.id); setCurrentWorkflowId(created.id);
setInvocations(created.invocations ?? []); setInvocations(created.invocations ?? []);
@ -440,12 +435,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]); }, [request, instanceId, 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, workflowId); const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) { if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations); handleFromApiGraph(wf.graph, wf.invocations);
} else { } else {
@ -467,7 +462,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setExecuteResult(null); setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, []); applyGraphWithSync({ nodes: [], connections: [] }, []);
try { try {
const result = await fetchWorkflows(request); const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items); setWorkflows(Array.isArray(result) ? result : result.items);
} catch (refreshErr) { } catch (refreshErr) {
console.error(`${LOG} workflows refresh failed`, refreshErr); console.error(`${LOG} workflows refresh failed`, refreshErr);
@ -480,7 +475,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
}); });
} }
}, },
[request, handleFromApiGraph, applyGraphWithSync, t] [request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
); );
const handleWorkflowSelect = useCallback( const handleWorkflowSelect = useCallback(
@ -548,10 +543,11 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
); );
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, mandateId || '', language); const data = await fetchNodeTypes(request, instanceId, language);
setNodeTypes(data.nodeTypes); setNodeTypes(data.nodeTypes);
setCategories(data.categories); setCategories(data.categories);
if (data.portTypeCatalog) { if (data.portTypeCatalog) {
@ -568,16 +564,17 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [language, request]); }, [instanceId, language, request]);
const loadWorkflows = useCallback(async () => { const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try { try {
const result = await fetchWorkflows(request, { mandateId: mandateId || undefined }); const result = await fetchWorkflows(request, instanceId);
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);
} }
}, [request, mandateId]); }, [instanceId, request]);
useEffect(() => { useEffect(() => {
loadNodeTypes(); loadNodeTypes();
@ -601,22 +598,8 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
useEffect(() => { useEffect(() => {
if (loading || nodeTypes.length === 0) return; if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) { if (currentWorkflowId || initialWorkflowId) return;
didBootstrapEmptyCanvasRef.current = false; if (canvasNodes.length > 0) return;
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: [] }, [], { applyGraphWithSync({ nodes: [], connections: [] }, [], {
skipHistory: true, skipHistory: true,
}); });
@ -626,9 +609,8 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
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) => {
@ -667,17 +649,17 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
); );
const loadVersions = useCallback(async () => { const loadVersions = useCallback(async () => {
if (!currentWorkflowId) { if (!instanceId || !currentWorkflowId) {
setVersions([]); setVersions([]);
return; return;
} }
try { try {
const v = await fetchVersions(request, currentWorkflowId); const v = await fetchVersions(request, instanceId, currentWorkflowId);
setVersions(v); setVersions(v);
} catch (e) { } catch (e) {
console.error(`${LOG} loadVersions failed`, e); console.error(`${LOG} loadVersions failed`, e);
} }
}, [currentWorkflowId, request]); }, [instanceId, currentWorkflowId, request]);
useEffect(() => { useEffect(() => {
loadVersions(); loadVersions();
@ -698,9 +680,10 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
const handlePublishVersion = useCallback( const handlePublishVersion = useCallback(
async (versionId: string) => { async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
await publishVersion(request, versionId); await publishVersion(request, instanceId, 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) });
@ -708,14 +691,15 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, loadVersions] [request, instanceId, 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, versionId); await unpublishVersion(request, instanceId, 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) });
@ -723,14 +707,15 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, loadVersions] [request, instanceId, 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, versionId); await archiveVersion(request, instanceId, 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) });
@ -738,14 +723,14 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setVersionLoading(false); setVersionLoading(false);
} }
}, },
[request, loadVersions] [request, instanceId, loadVersions]
); );
const handleCreateDraft = useCallback(async () => { const handleCreateDraft = useCallback(async () => {
if (!currentWorkflowId) return; if (!instanceId || !currentWorkflowId) return;
setVersionLoading(true); setVersionLoading(true);
try { try {
const draft = await createDraftVersion(request, currentWorkflowId); const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
await loadVersions(); await loadVersions();
setCurrentVersionId(draft.id); setCurrentVersionId(draft.id);
} catch (e: unknown) { } catch (e: unknown) {
@ -753,16 +738,16 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
} finally { } finally {
setVersionLoading(false); setVersionLoading(false);
} }
}, [request, currentWorkflowId, loadVersions]); }, [request, instanceId, 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 (!currentWorkflowId) return; if (!instanceId || !currentWorkflowId) return;
setTemplateSaving(true); setTemplateSaving(true);
try { try {
await createTemplateFromWorkflow(request, currentWorkflowId, scope); await createTemplateFromWorkflow(request, instanceId, 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) });
@ -770,15 +755,16 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setTemplateSaving(false); setTemplateSaving(false);
} }
}, },
[request, currentWorkflowId] [request, instanceId, 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, templateId); const wf = await copyTemplate(request, instanceId, 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);
@ -787,7 +773,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} }
}, },
[request, handleFromApiGraph] [request, instanceId, handleFromApiGraph]
); );
@ -945,20 +931,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
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={async (fileId, fileName) => { onFileSelect={onFileSelect}
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}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
}}
/> />
)} )}
</div> </div>
@ -1030,12 +1008,12 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
stickyNotes={canvasStickyNotes} stickyNotes={canvasStickyNotes}
onStickyNotesChange={setCanvasStickyNotes} onStickyNotesChange={setCanvasStickyNotes}
onExternalDrop={async (mime, payload) => { onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow') return false; if (mime !== 'application/json+workflow' || !instanceId) return false;
const p = payload as { files?: Array<{ id: string }> } | undefined; const p = payload as { files?: Array<{ id: string }> } | undefined;
const fileId = p?.files?.[0]?.id; const fileId = p?.files?.[0]?.id;
if (!fileId) return false; if (!fileId) return false;
try { try {
const result = await importWorkflowFromFile(request, { fileId }); const result = await importWorkflowFromFile(request, instanceId, { fileId });
await loadWorkflows(); await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true; return true;
@ -1048,7 +1026,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
</div> </div>
{configurableSelected && selectedNode && ( {configurableSelected && selectedNode && (
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys=""> <div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
<WorkflowDataFlowProvider <Automation2DataFlowProvider
node={selectedNode} node={selectedNode}
nodes={canvasNodes} nodes={canvasNodes}
connections={canvasConnections} connections={canvasConnections}
@ -1073,7 +1051,7 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
request={request} request={request}
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
/> />
</WorkflowDataFlowProvider> </Automation2DataFlowProvider>
</div> </div>
)} )}
</div> </div>
@ -1128,4 +1106,4 @@ export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instance
); );
}; };
export default WorkflowFlowEditor; export default Automation2FlowEditor;

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* CanvasHeader - Workflow controls, version selector, and execute result. * CanvasHeader - Workflow controls, version selector, and execute result.
*/ */
@ -25,11 +23,12 @@ import {
HiOutlineArrowUturnRight, HiOutlineArrowUturnRight,
HiOutlineTrash, HiOutlineTrash,
HiOutlineDocumentDuplicate, HiOutlineDocumentDuplicate,
HiOutlineArrowLongRight,
HiOutlineChatBubbleLeftEllipsis, HiOutlineChatBubbleLeftEllipsis,
HiOutlineSquares2X2, HiOutlineSquares2X2,
} from 'react-icons/hi2'; } from 'react-icons/hi2';
import type { WorkflowDefinition, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowAutomationApi'; import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache'; import { getUserDataCache } from '../../../utils/userCache';
@ -62,7 +61,7 @@ export interface CanvasHeaderCanvasEditProps {
} }
interface CanvasHeaderProps { interface CanvasHeaderProps {
workflows: WorkflowDefinition[]; workflows: Automation2Workflow[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void; onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;

View file

@ -1,9 +1,7 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorChatPanel * EditorChatPanel
* *
* AI Chat sidebar for the WorkflowAutomation editor. * AI Chat sidebar for the GraphicalEditor.
* 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 FilesTab (UDB) onto input area, or click in UDB * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
@ -89,7 +87,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/workflow-automation/{workflowId}/chat/messages`. // returned by `GET /api/workflows/{instanceId}/{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) {
@ -101,7 +99,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/workflow-automation/${workflowId}/chat/messages`, `/api/workflows/${instanceId}/${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 => ({
@ -168,7 +166,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/workflow-automation/${workflowId}/chat/stream`, url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body, body,
handlers: { handlers: {
onChunk: (event) => { onChunk: (event) => {
@ -229,7 +227,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
: m)); : m));
} }
try { try {
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`); await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
} catch { } catch {
} }
abortRef.current?.(); abortRef.current?.();

View file

@ -1,19 +1,17 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* EditorWorkflowChatList * EditorWorkflowChatList
* *
* UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow * UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
* is treated as one editor chat session. Lists workflows already loaded by the * as one editor chat session. Lists workflows already loaded by the parent
* parent editor (no extra fetch), supports search and "+ Neu" to start a fresh * 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
* WorkflowAutomation data instead of the workspace endpoint. * GraphicalEditor data instead of the workspace endpoint.
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import type { WorkflowDefinition } from '../../../api/workflowAutomationApi'; import type { Automation2Workflow } from '../../../api/workflowApi';
interface EditorWorkflowChatListProps { interface EditorWorkflowChatListProps {
workflows: WorkflowDefinition[]; workflows: Automation2Workflow[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
onSelect: (workflowId: string | null) => void; onSelect: (workflowId: string | null) => void;
onNew: () => void; onNew: () => void;

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* FlowCanvas - Workflow graph canvas with nodes and connection lines. * FlowCanvas - Workflow graph canvas with nodes and connection lines.
* Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows. * Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows.
@ -15,15 +13,13 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowAutomationApi'; import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
import styles from './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
import { switchOutputLabel } from '../nodes/shared/graphUtils'; import { switchOutputLabel } from '../nodes/shared/graphUtils';
const LOG = '[FlowCanvas]';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
type: string; type: string;
@ -846,8 +842,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const onHistoryCheckpointRef = useRef(onHistoryCheckpoint); const onHistoryCheckpointRef = useRef(onHistoryCheckpoint);
onHistoryCheckpointRef.current = onHistoryCheckpoint; onHistoryCheckpointRef.current = onHistoryCheckpoint;
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const emitHistoryCheckpoint = useCallback(() => { const emitHistoryCheckpoint = useCallback(() => {
onHistoryCheckpointRef.current?.(); onHistoryCheckpointRef.current?.();
@ -1025,19 +1019,12 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
] ]
); );
const lastEmittedSelectionRef = useRef<{ nodeId: string | null; signature: string | null }>({
nodeId: null,
signature: null,
});
useEffect(() => { useEffect(() => {
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null; if (onSelectionChange) {
const signature = node ? JSON.stringify(node) : null; const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
const last = lastEmittedSelectionRef.current; onSelectionChange(node);
if (last.nodeId === selectedNodeId && last.signature === signature) return; }
lastEmittedSelectionRef.current = { nodeId: selectedNodeId, signature }; }, [selectedNodeId, nodes, onSelectionChange]);
onSelectionChangeRef.current?.(node);
}, [selectedNodeId, nodes]);
const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => { const handleConnectionClick = useCallback((e: React.MouseEvent, connId: string) => {
e.stopPropagation(); e.stopPropagation();
@ -1101,11 +1088,6 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
const handleDrop = useCallback( const handleDrop = useCallback(
async (e: React.DragEvent) => { async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
console.debug(`${LOG} drop received`, {
types: Array.from(e.dataTransfer.types),
clientX: e.clientX,
clientY: e.clientY,
});
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab) // 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) { if (onExternalDrop) {
const reservedMimes = new Set([ const reservedMimes = new Set([
@ -1131,35 +1113,16 @@ export const FlowCanvas = forwardRef<FlowCanvasHandle, FlowCanvasProps>(function
} }
// 2) Standard: Node-Type aus der NodeSidebar // 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json'); const raw = e.dataTransfer.getData('application/json');
if (!raw || !containerRef.current) { if (!raw || !containerRef.current) return;
console.debug(`${LOG} drop ignored`, {
hasRaw: Boolean(raw),
hasContainer: Boolean(containerRef.current),
});
return;
}
try { try {
const { type } = JSON.parse(raw); const { type } = JSON.parse(raw);
const el = containerRef.current; const el = containerRef.current;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2; const x = (e.clientX - rect.left - panOffset.x) / zoom - NODE_WIDTH / 2;
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2; const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
console.debug(`${LOG} placing node from drop`, {
type,
raw,
dropX: x,
dropY: y,
panOffset,
zoom,
});
onDropNodeType(type, Math.max(0, x), Math.max(0, y)); onDropNodeType(type, Math.max(0, x), Math.max(0, y));
emitHistoryCheckpoint(); emitHistoryCheckpoint();
} catch (error) { } catch (_) {}
console.debug(`${LOG} drop parse failed`, {
raw,
error,
});
}
}, },
[onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint] [onDropNodeType, onExternalDrop, panOffset, zoom, emitHistoryCheckpoint]
); );

View file

@ -1,5 +1,3 @@
// 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.
@ -7,15 +5,14 @@
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi'; import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowAutomationApi'; import type { ApiRequestFunction } from '../../../api/workflowApi';
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 { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker'; import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation'; import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { AccordionList } from '../../UiComponents/AccordionList'; import { AccordionList } from '../../UiComponents/AccordionList';
@ -212,7 +209,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange] [onParametersChange]
); );
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {}; const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User // Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
@ -256,7 +253,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
for (const param of sortedParameters) { for (const param of sortedParameters) {
if (param.frontendType === 'hidden') continue; if (param.frontendType === 'hidden') continue;
if (param.name === 'context') continue;
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue; if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue; if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
@ -382,15 +378,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
t, 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.');
@ -496,71 +483,11 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
</div> </div>
)} )}
{extractContentAccordionItems !== null ? ( {extractContentAccordionItems !== null ? (
<> <AccordionList<string>
{extractContentContextParam ? ( key={`${node.id}-extract-accordion`}
<div defaultOpenId={null}
key={`${node.id}-${extractContentContextParam.name}`} items={extractContentAccordionItems}
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) => { parameters.map((param: NodeTypeParameter) => {
// Safety net: hidden params have no UI footprint at all — no row, // Safety net: hidden params have no UI footprint at all — no row,

View file

@ -1,16 +1,14 @@
// 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/workflowAutomationApi'; import type { NodeType } from '../../../api/workflowApi';
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 './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { AiBadge } from '../nodes/shared/AiBadge'; import { AiBadge } from '../nodes/shared/AiBadge';
interface NodeListItemProps { interface NodeListItemProps {

View file

@ -1,5 +1,3 @@
// 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 (start, input, flow, data, ai, email, sharepoint). * Groups node types by category (start, input, flow, data, ai, email, sharepoint).
@ -7,11 +5,11 @@
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/workflowAutomationApi'; import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
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 './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* RunTracingPanel * RunTracingPanel
* *
@ -9,7 +7,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/workflowAutomationApi'; import type { AutoStepLog } from '../../../api/workflowApi';
import api from '../../../api'; import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -100,7 +98,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
setLoading(true); setLoading(true);
try { try {
const data = await request({ const data = await request({
url: `/api/workflow-automation/runs/${runId}/steps`, url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
method: 'get', method: 'get',
}); });
setSteps(data?.steps || []); setSteps(data?.steps || []);
@ -117,7 +115,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
loadSteps(); loadSteps();
const baseUrl = api.defaults.baseURL || ''; const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`; const url = `${baseUrl}/api/workflows/${instanceId}/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,5 +1,3 @@
// 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.
*/ */
@ -11,8 +9,8 @@ import {
type AutoWorkflowTemplate, type AutoWorkflowTemplate,
type AutoTemplateScope, type AutoTemplateScope,
type ApiRequestFunction, type ApiRequestFunction,
} from '../../../api/workflowAutomationApi'; } from '../../../api/workflowApi';
import styles from './WorkflowFlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps { interface TemplatePickerProps {
@ -52,7 +50,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, scope); const result = await fetchTemplates(request, instanceId, scope);
setTemplates(Array.isArray(result) ? result : result.items); setTemplates(Array.isArray(result) ? result : result.items);
} catch { } catch {
setTemplates([]); setTemplates([]);

View file

@ -1,6 +1,4 @@
// Copyright (c) 2026 PowerOn AG export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
// 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, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } 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, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas'; export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* One text field per option the text the end user sees in the dropdown. * 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. * Stored as { value, label } with the same string so payload and UI stay in sync.

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Form node config - draggable fields, types, required toggle * Form node config - draggable fields, types, required toggle
*/ */
@ -8,8 +6,8 @@ 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 { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/WorkflowFlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor'; import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
import { import {
deriveFormFieldPayloadKey, deriveFormFieldPayloadKey,
@ -21,7 +19,7 @@ import { useLanguage } from '../../../../providers/language/LanguageContext';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useWorkflowDataFlow(); const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Helpers for optional select/multiselect rows on workflow form field definitions. * Helpers for optional select/multiselect rows on workflow form field definitions.
*/ */

View file

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

View file

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Backend-driven case list for flow.switch (depends on value dataRef). * Backend-driven case list for flow.switch (depends on value dataRef).
*/ */
import React from 'react'; import React from 'react';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef'; import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils'; import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi'; import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase { export interface SwitchCase {
@ -118,7 +116,7 @@ export const CaseListEditor: React.FC<FieldRendererProps> = ({
allParams, allParams,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const dependsOn = const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object' param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value') ? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
@ -159,7 +157,7 @@ export const CaseListEditor: React.FC<FieldRendererProps> = ({
if (dataFlow?.instanceId && dataFlow.request) { if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true); setLoading(true);
fetchConditionMeta(dataFlow.request, { fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections), graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId, nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path }, ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },

View file

@ -1,395 +0,0 @@
// 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

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef). * Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
*/ */
import React from 'react'; import React from 'react';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, type DataRef } from '../shared/dataRef'; import { isRef, type DataRef } from '../shared/dataRef';
import { toApiGraph } from '../shared/graphUtils'; import { toApiGraph } from '../shared/graphUtils';
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi'; import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition { export interface StructuredCondition {
@ -43,7 +41,7 @@ export const ConditionEditor: React.FC<FieldRendererProps> = ({
allParams, allParams,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const dependsOn = const dependsOn =
param.frontendOptions && typeof param.frontendOptions === 'object' param.frontendOptions && typeof param.frontendOptions === 'object'
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item') ? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
@ -85,7 +83,7 @@ export const ConditionEditor: React.FC<FieldRendererProps> = ({
if (dataFlow?.instanceId && dataFlow.request) { if (dataFlow?.instanceId && dataFlow.request) {
setLoading(true); setLoading(true);
fetchConditionMeta(dataFlow.request, { fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
graph: toApiGraph(dataFlow.nodes, dataFlow.connections), graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
nodeId: dataFlow.currentNodeId, nodeId: dataFlow.currentNodeId,
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path }, ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* One place to configure context.setContext rows: target key, then either * One place to configure context.setContext rows: target key, then either
* upstream picker, a fixed literal, or a human task. * upstream picker, a fixed literal, or a human task.
@ -7,7 +5,7 @@
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
@ -176,7 +174,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => { export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const rows = normalizeRows(value, allParams); const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null); const [pickerRow, setPickerRow] = React.useState<number | null>(null);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* ContextBuilderRenderer multi-select context binding for AI nodes. * ContextBuilderRenderer multi-select context binding for AI nodes.
* *
@ -13,7 +11,7 @@
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
@ -54,7 +52,7 @@ const REMOVE_BTN: React.CSSProperties = {
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
const dragIndex = React.useRef<number | null>(null); const dragIndex = React.useRef<number | null>(null);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* DataRefRenderer Pick-not-Push attribute binding using the existing * DataRefRenderer Pick-not-Push attribute binding using the existing
* hierarchical DataPicker. * hierarchical DataPicker.
@ -12,14 +10,14 @@
import React from 'react'; import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false); const [pickerOpen, setPickerOpen] = React.useState(false);
const currentRef = isRef(value) ? (value as DataRef) : null; const currentRef = isRef(value) ? (value as DataRef) : null;

View file

@ -1,11 +1,9 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* FeatureInstancePicker renderer for frontendType="featureInstance". * FeatureInstancePicker renderer for frontendType="featureInstance".
* *
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered * Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via * by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
* GET /api/workflow-automation/options/feature.instance?featureCode=<code> * GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
* *
* Behavior matches the rest of the editor: * Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate * - 0 results -> hint to create a feature instance for this mandate
@ -44,7 +42,7 @@ export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
setLoading(true); setLoading(true);
setLoadError(null); setLoadError(null);
request({ request({
url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`, url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get', method: 'get',
}) })
.then((res: unknown) => { .then((res: unknown) => {

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens. * TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway). * Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
@ -7,11 +5,11 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index'; import type { FieldRendererProps } from './index';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker'; import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef'; import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/WorkflowFlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g; const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
@ -62,7 +60,7 @@ function _parseTokensInTemplate(
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree. * userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/ */

View file

@ -1,30 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { describe, expect, it } from 'vitest';
import {
clickupBrowseParentPath,
formatListPickerValue,
parseClickupListPath,
} from './clickupPathUtils';
describe('clickupPathUtils', () => {
it('parseClickupListPath extracts team and list ids', () => {
expect(parseClickupListPath('/team/abc/list/xyz')).toEqual({
teamId: 'abc',
listId: 'xyz',
});
expect(parseClickupListPath('')).toEqual({});
});
it('formatListPickerValue stores path or listId by param name', () => {
const path = '/team/abc/list/xyz';
expect(formatListPickerValue(path, 'pathQuery')).toBe(path);
expect(formatListPickerValue(path, 'listId')).toBe('xyz');
});
it('clickupBrowseParentPath walks up hierarchy', () => {
expect(clickupBrowseParentPath('/')).toBe('/');
expect(clickupBrowseParentPath('/team/t1')).toBe('/');
expect(clickupBrowseParentPath('/team/t1/space/s1')).toBe('/team/t1');
});
});

View file

@ -1,63 +0,0 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** Parse virtual ClickUp list paths: /team/{teamId}/list/{listId} */
const LIST_PATH_RE = /^\/team\/([^/]+)\/list\/([^/]+)$/;
export function parseClickupListPath(path: string): { teamId?: string; listId?: string } {
const p = (path || '').trim();
const m = p.match(LIST_PATH_RE);
if (!m) return {};
return { teamId: m[1], listId: m[2] };
}
/** Store path for pathQuery; raw list id for listId param. */
export function formatListPickerValue(listPath: string, paramName: string): string {
const { listId } = parseClickupListPath(listPath);
if (paramName === 'listId' && listId) return listId;
return listPath;
}
/** Resolve list path from stored value (path or legacy raw id). */
export function resolveListPathFromValue(value: string, paramName: string): string | null {
const v = (value || '').trim();
if (!v) return null;
if (LIST_PATH_RE.test(v)) return v;
if (paramName === 'listId' && /^[a-zA-Z0-9_-]+$/.test(v)) {
return null;
}
return v;
}
export function clickupBrowseParentPath(path: string): string {
const p = (path || '/').trim() || '/';
if (p === '/') return '/';
const folder = p.match(/^(\/team\/[^/]+\/space\/[^/]+)\/folder\/[^/]+$/);
if (folder) return folder[1];
const space = p.match(/^(\/team\/[^/]+)\/space\/[^/]+$/);
if (space) return space[1];
if (/^\/team\/[^/]+$/.test(p)) return '/';
const parts = p.split('/').filter(Boolean);
if (parts.length <= 1) return '/';
parts.pop();
return `/${parts.join('/')}`;
}
export function cuTypeFromEntry(metadata?: Record<string, unknown>): string {
const t = metadata?.cuType;
return typeof t === 'string' ? t : '';
}
export function isClickupListEntry(metadata?: Record<string, unknown>): boolean {
return cuTypeFromEntry(metadata) === 'list';
}
export function isClickupContainerEntry(
metadata: Record<string, unknown> | undefined,
isFolder: boolean,
): boolean {
const cu = cuTypeFromEntry(metadata);
if (cu === 'list') return false;
if (cu === 'team' || cu === 'space' || cu === 'folder') return true;
return isFolder && cu !== 'task';
}

View file

@ -1,15 +1,13 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Generic FrontendType renderer registry. * Generic FrontendType renderer registry.
* Maps frontendType strings to React components. * Maps frontendType strings to React components.
*/ */
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowAutomationApi'; import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowAutomationApi'; import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper'; import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor'; import { FormFieldOptionsEditor } from '../form/FormFieldOptionsEditor';
import { import {
deriveFormFieldPayloadKey, deriveFormFieldPayloadKey,
@ -48,14 +46,13 @@ import {
import { useLanguage } from '../../../../providers/language/LanguageContext'; import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils'; import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowAutomationApi'; import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer'; import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor'; import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker'; import { UserFileFolderPicker } from './UserFileFolderPicker';
import { ClickUpListPicker } from './ClickUpListPicker';
import { ConditionEditor } from './ConditionEditor'; import { ConditionEditor } from './ConditionEditor';
import { CaseListEditor } from './CaseListEditor'; import { CaseListEditor } from './CaseListEditor';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer'; import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
@ -302,7 +299,7 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => { const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const dataFlow = useWorkflowDataFlow(); const dataFlow = useAutomation2DataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]); const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null); const [loadError, setLoadError] = React.useState<string | null>(null);
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]); const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
@ -312,7 +309,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
if (!instanceId || !request) return; if (!instanceId || !request) return;
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : ''; const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
setLoadError(null); setLoadError(null);
request({ url: `/api/workflow-automation/options/user.connection${qs}`, method: 'get' }) request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => { .then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> }; const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label }))); setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
@ -330,7 +327,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
return; return;
} }
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections); const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, graph, dataFlow.currentNodeId) postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
.then(({ paths }) => { .then(({ paths }) => {
const opts = paths const opts = paths
.filter( .filter(
@ -645,7 +642,7 @@ const SharepointPathPicker: React.FC<FieldRendererProps> = ({ param, value, onCh
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => { const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const ctx = useWorkflowDataFlow(); const ctx = useAutomation2DataFlow();
const fieldTypeOptions = ctx?.formFieldTypes?.length const fieldTypeOptions = ctx?.formFieldTypes?.length
? ctx.formFieldTypes ? ctx.formFieldTypes
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' })); : FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
@ -757,10 +754,7 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
onChange={(e) => { onChange={(e) => {
const typeId = e.target.value; const typeId = e.target.value;
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])]; const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
const subRow: Record<string, unknown> = { const subRow = { ...(nextFields[j] as Record<string, unknown>), type: typeId };
...(nextFields[j] as Record<string, unknown>),
type: typeId,
};
if (formFieldTypeHasConfigurableOptions(typeId)) { if (formFieldTypeHasConfigurableOptions(typeId)) {
subRow.options = normalizeFormFieldOptions(subRow.options); subRow.options = normalizeFormFieldOptions(subRow.options);
} }
@ -1071,7 +1065,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
sharepointFolder: SharepointPathPicker, sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker, sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker, userFileFolder: UserFileFolderPicker,
clickupList: ClickUpListPicker, clickupList: FolderPicker,
clickupTask: FolderPicker, clickupTask: FolderPicker,
caseList: CaseListEditor, caseList: CaseListEditor,
fieldBuilder: FieldBuilderEditor, fieldBuilder: FieldBuilderEditor,

View file

@ -1,4 +1,2 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor'; export { ConditionEditor as IfElseNodeConfig } from '../frontendTypeRenderers/ConditionEditor';
export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor'; export type { StructuredCondition } from '../frontendTypeRenderers/ConditionEditor';

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/** /**
* Loop node config - Datenquelle für Iteration mit benutzerfreundlichen Labels. * Loop node config - Datenquelle für Iteration mit benutzerfreundlichen Labels.
* Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche. * Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.
@ -9,7 +7,7 @@ import React from 'react';
import type { NodeConfigRendererProps } from '../shared/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { LoopItemsSelect } from '../shared/LoopItemsSelect'; import { LoopItemsSelect } from '../shared/LoopItemsSelect';
import { createValue, isRef, isValue } from '../shared/dataRef'; import { createValue, isRef, isValue } from '../shared/dataRef';
import styles from '../../editor/WorkflowFlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
export const LoopNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const LoopNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const value = params.items; const value = params.items;

View file

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

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