Compare commits

..

No commits in common. "main" and "HEAD" have entirely different histories.
main ... HEAD

549 changed files with 34715 additions and 47330 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.int .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for integration
run: npm run build:int
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend-int",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla-int'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
package: ./deploy

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.prod .env
- name: Install dependencies
run: |
npm ci
npm install express
- name: Build React app for production
run: npm run build:prod
- name: Prepare deployment package
run: |
# Create deployment package with build files and necessary configs
mkdir deploy
cp -r dist/* deploy/
# Create a simple server.js for serving the app
echo "const express = require('express');" > deploy/server.js
echo "const path = require('path');" >> deploy/server.js
echo "const app = express();" >> deploy/server.js
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
# Create a new package.json for deployment
echo '{
"name": "frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2"
}
}' > deploy/package.json
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3
with:
app-name: 'poweron-nyla'
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
package: ./deploy

9
.gitignore vendored
View file

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

View file

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

34
config/.env.dev Normal file
View file

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

33
config/.env.int Normal file
View file

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

33
config/.env.prod Normal file
View file

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

View file

@ -1,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).
*
* 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.
* Simple Configuration Service
* Centralized access to environment variables with fallbacks
*/
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) {
throw new Error(
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
);
}
export const getApiTimeout = (): number => {
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
};
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

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

View file

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

View file

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

View file

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

17
env.d.ts vendored
View file

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

View file

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

1654
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -185,10 +185,7 @@
</div>
<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><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>
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</body>

View file

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

View file

@ -153,7 +153,7 @@
</div>
<div class="last-updated">
<strong>Last Updated:</strong> May 2026
<strong>Last Updated:</strong> August 2025
</div>
<div class="content-section">
@ -315,13 +315,8 @@
<h2>Contact Information</h2>
<p>If you have any questions about these Terms of Service, please contact us:</p>
<div class="highlight-box">
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
<p><strong>Address:</strong><br>
PowerOn AG<br>
Birmensdorferstrasse 94<br>
CH-8003 Zürich<br>
Switzerland
</p>
<p><strong>Email:</strong> legal@poweron-ai.com</p>
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
</div>
</div>
@ -331,7 +326,7 @@
</div>
<div class="footer">
<p>&copy; 2026 PowerOn. All rights reserved.</p>
<p>&copy; 2025 PowerOn AI Platform. All rights reserved.</p>
</div>
</div>
</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
*
@ -27,6 +25,7 @@ import Reset from './pages/Reset';
import { InvitePage } from './pages/InvitePage';
// Providers
import { AuthProvider } from './providers/auth/AuthProvider';
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
@ -41,12 +40,11 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
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 { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
import { RagInventoryPage } from './pages/RagInventoryPage';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
function App() {
// Load saved theme preference and set app name on app mount
@ -73,6 +71,7 @@ function App() {
return (
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<VoiceCatalogProvider>
<WorkflowSelectionProvider>
@ -126,20 +125,15 @@ function App() {
</Route>
{/* ============================================== */}
{/* WORKFLOW AUTOMATION (System-Komponente) */}
{/* AUTOMATIONS DASHBOARD */}
{/* ============================================== */}
<Route path="workflow-automation" element={<WorkflowAutomationPage />} />
{/* ============================================== */}
{/* RAG INVENTORY */}
{/* ============================================== */}
<Route path="rag-inventory" element={<RagInventoryPage />} />
<Route path="automations" element={<AutomationsDashboardPage />} />
{/* 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="speech" element={<Navigate to="/" replace />} />
{/* ============================================== */}
{/* FEATURE-INSTANZ ROUTES */}
{/* /mandates/:mandateId/:featureCode/:instanceId */}
@ -153,7 +147,8 @@ function App() {
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
@ -163,7 +158,8 @@ function App() {
<Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
@ -173,26 +169,24 @@ function App() {
<Route path="templates" element={<FeatureViewPage view="templates" />} />
<Route path="logs" element={<FeatureViewPage view="logs" />} />
{/* Workspace Editor */}
{/* Workspace + Automation2 Editor */}
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */}
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
<Route path="settings" element={<FeatureViewPage view="settings" />} />
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
<Route path="modules" element={<FeatureViewPage view="modules" />} />
{/* Neutralization Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
{/* CommCoach Feature Views */}
<Route path="session" element={<FeatureViewPage view="session" />} />
{/* Redmine Feature Views */}
<Route path="stats" element={<FeatureViewPage view="stats" />} />
<Route path="browser" element={<FeatureViewPage view="browser" />} />
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
@ -221,7 +215,7 @@ function App() {
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<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="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
@ -241,6 +235,7 @@ function App() {
</WorkflowSelectionProvider>
</VoiceCatalogProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>
);
}

View file

@ -1,10 +1,25 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
// api.ts
import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
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.
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
@ -29,25 +44,45 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
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({
baseURL: _baseUrl,
withCredentials: true,
paramsSerializer: { indexes: null },
baseURL: getApiBaseUrl(),
withCredentials: true
});
// 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(
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');
if (authToken && config.headers) {
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
@ -57,20 +92,6 @@ api.interceptors.request.use(
config.headers['Accept-Language'] = appLanguage;
}
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
// resolve "now" for AI agents and user-visible time strings without
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
if (config.headers) {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone) {
config.headers['X-User-Timezone'] = browserTimezone;
}
} catch {
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
}
}
// Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl();

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

827
src/api/workflowApi.ts Normal file
View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* 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.
*

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList';
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 { 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 { IoIosDownload } from 'react-icons/io';
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 type { ContentPreviewProps } from './ContentPreview';
export { UrlContentPreview } from './UrlContentPreview';

View file

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

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useLanguage } from '../../../providers/language/LanguageContext';
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 * as XLSX from 'xlsx';
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';
interface HtmlRendererProps {

View file

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

View file

@ -1,5 +1,3 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
import { useState } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
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 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';
// @ts-ignore
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 { useLanguage } from '../../../providers/language/LanguageContext';
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 { 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 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 { renderAsync } from 'docx-preview';
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 { ImageRenderer } from './ImageRenderer';
export { TextRenderer } from './TextRenderer';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,16 +1,14 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
/**
* NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups.
*/
import React from 'react';
import type { NodeType } from '../../../api/workflowAutomationApi';
import type { NodeType } from '../../../api/workflowApi';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getCategoryIcon } 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';
interface NodeListItemProps {

View file

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

View file

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

View file

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

View file

@ -1,14 +1,11 @@
// Copyright (c) 2026 PowerOn AG
// All rights reserved.
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
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 type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';
export { NodeSidebar } from './editor/NodeSidebar';
export { NodeListItem } from './editor/NodeListItem';
export { CanvasHeader } from './editor/CanvasHeader';
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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