Compare commits
172 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f47dd395c | |||
| 5109279ebd | |||
| 6520763736 | |||
| 7eb305f910 | |||
| a13a158c67 | |||
| cd14babb2e | |||
| d398907edc | |||
| 49c3cf7290 | |||
| 19a39bc443 | |||
| 7a914ce2d9 | |||
| b21fa78665 | |||
| 78457a7d27 | |||
| 31ed78e863 | |||
|
|
059bbe956a | ||
|
|
b36b303def | ||
| 30db1b8316 | |||
| 4475a45a26 | |||
| 5ce871fb3c | |||
| 59b1e1f6a7 | |||
| 991952dde9 | |||
| 7876a528f5 | |||
| ff68307a39 | |||
| beeed79aaa | |||
| 2031c87529 | |||
| e94420dfe9 | |||
| aa982680fa | |||
| e727996a18 | |||
| 5711450606 | |||
| 76f35a7f22 | |||
| 5aacf17b13 | |||
| ece5f17e2a | |||
| 8e67efa092 | |||
| 9d081e8819 | |||
| 1c539076e5 | |||
| 7da7ad5041 | |||
| 6c319a4170 | |||
| bf4916b447 | |||
| ab5ead3416 | |||
| 275b5125c1 | |||
| 57319507bb | |||
| f27bfd2221 | |||
| 8f9d233d8c | |||
| 5a5d24bbe2 | |||
| 639cac2e33 | |||
| 0331a59da3 | |||
| 3cc2f4decf | |||
| 12868fdd17 | |||
| 50c05e91d7 | |||
| 036e6a38db | |||
| 8d24d57719 | |||
| 554d798ae2 | |||
| 9047304934 | |||
| 7a228f0181 | |||
| a7921d409e | |||
| 077dbca759 | |||
| 6dbf91afb2 | |||
| f35e22c7f4 | |||
| 234ffa7896 | |||
| 86a3ac647c | |||
| 0f1f9781b7 | |||
| 1a4f18392c | |||
| 9d18e743bc | |||
| 638f18cd55 | |||
| f1234fedb3 | |||
|
|
4f2745cc2e | ||
|
|
1308e6d415 | ||
|
|
65170d9e4c | ||
|
|
f37774ff36 | ||
|
|
ca6261fb1a | ||
|
|
bb441f5268 | ||
|
|
e1260c173c | ||
|
|
12e10350d9 | ||
|
|
de8007644f | ||
|
|
abdb499067 | ||
|
|
629333f910 | ||
|
|
8c0e2ee8af | ||
|
|
0e89ed2a64 | ||
|
|
ba5b0fa8e8 | ||
|
|
230055a4fb | ||
|
|
0d8e6501d3 | ||
|
|
2ee08c314b | ||
|
|
ccb2798170 | ||
|
|
a6b37ed684 | ||
|
|
791d575b7d | ||
|
|
544f36460a | ||
|
|
5c55312c60 | ||
|
|
9b6edec74e | ||
|
|
618574bc76 | ||
|
|
98e7679464 | ||
|
|
3477126d9f | ||
|
|
f5a5793309 | ||
|
|
77f4693f4b | ||
|
|
5c8c80872a | ||
|
|
a96295490f | ||
|
|
bb9fd56bc6 | ||
|
|
5eb9253fb1 | ||
|
|
a912db3fee | ||
| 877b4fec7e | |||
| 25b56f585e | |||
| 930a34662d | |||
|
|
956a226b1b | ||
|
|
d42fa02736 | ||
|
|
dca587a2df | ||
|
|
79557e51ed | ||
|
|
2739b145db | ||
|
|
73d03b364b | ||
|
|
1219d616d4 | ||
|
|
1741f497ec | ||
|
|
0055b8ce44 | ||
|
|
d11280fe34 | ||
| 3d580a5fca | |||
| 1d2d247273 | |||
|
|
f6fa57180e | ||
|
|
992c0472c6 | ||
|
|
02bf4020d7 | ||
|
|
c7e94aea79 | ||
| 7c05cb0dd7 | |||
| e7a79a3484 | |||
|
|
ad96c6d861 | ||
|
|
70459d57e3 | ||
|
|
8cecf3b320 | ||
| aff9dcb7bd | |||
| 31586d62c1 | |||
| c8e9304801 | |||
| b61544d8b1 | |||
|
|
26958d1e16 | ||
|
|
28951a7d22 | ||
|
|
9e08953c44 | ||
|
|
a0c2323fe6 | ||
|
|
34d6c2b83d | ||
|
|
3f80d6d434 | ||
|
|
3016806db9 | ||
|
|
974c48e24d | ||
|
|
fe857d5ade | ||
|
|
a9e8e8cddd | ||
|
|
2994f3a090 | ||
|
|
f0e73b62d2 | ||
|
|
8679cdffcb | ||
|
|
d8ff3a84d9 | ||
|
|
c47dc67a84 | ||
|
|
e09ed758ff | ||
|
|
fc2cce8732 | ||
|
|
0270f59d44 | ||
|
|
208f7b63df | ||
|
|
1c2a196192 | ||
|
|
c702740714 | ||
|
|
0bdaf86153 | ||
|
|
ebaaef7b4e | ||
|
|
d771d4bc09 | ||
| 4356394fd8 | |||
| 02e7701329 | |||
| 1c6f1ac435 | |||
| 98a14a5394 | |||
| bcc632927d | |||
| c5b27c0fbd | |||
| a9bdb2d4d4 | |||
| 893326e51d | |||
|
|
9093827e7c | ||
|
|
1c4233c7ea | ||
|
|
9e872910f4 | ||
|
|
45ea3ed48b | ||
|
|
8e5a01df6d | ||
|
|
3997c6ec63 | ||
|
|
3f4a98381d | ||
|
|
b4574b6a2e | ||
|
|
46d6ad1dfa | ||
|
|
7d84160cdb | ||
|
|
629d26c404 | ||
|
|
7c35c7117b | ||
|
|
99ba9fb607 | ||
|
|
59017138ff | ||
|
|
be748b162c |
549 changed files with 47336 additions and 34721 deletions
|
|
@ -1,144 +0,0 @@
|
|||
# Implement RBAC Roles Page
|
||||
|
||||
## Overview
|
||||
Implement the RBAC roles admin page following the exact pattern used in `mandates.ts`. This includes creating the API file, custom hook for state management, updating the page configuration with CreateButton header button, and adding translations in all three languages (German, English, French).
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### 1. Create API File: `frontend_nyla/src/api/roleApi.ts`
|
||||
- Follow the pattern from `mandateApi.ts`
|
||||
- Implement all required endpoints:
|
||||
- `fetchRoles()` - GET /api/rbac/roles (with pagination support)
|
||||
- `fetchRoleById()` - GET /api/rbac/roles/{roleId}
|
||||
- `fetchRoleOptions()` - GET /api/rbac/roles/options
|
||||
- `createRole()` - POST /api/rbac/roles
|
||||
- `updateRole()` - PUT /api/rbac/roles/{roleId}
|
||||
- `deleteRole()` - DELETE /api/rbac/roles/{roleId}
|
||||
- Include TypeScript types: `Role`, `RoleUpdateData`, `PaginationParams`, `PaginatedResponse`
|
||||
|
||||
### 2. Create Hook: `frontend_nyla/src/hooks/useAdminRbacRoles.ts`
|
||||
- Follow the exact pattern from `useAdminMandates.ts`
|
||||
- Create two hooks:
|
||||
- `useRbacRoles()` - Main hook for data fetching and state management
|
||||
- Fetch roles with pagination support
|
||||
- Fetch attributes from `/api/attributes/Role` using `fetchAttributes(request, 'Role')`
|
||||
- Fetch permissions using `checkPermission('DATA', 'Role')`
|
||||
- Implement `generateEditFieldsFromAttributes()` using `attributeTypeMapper` utilities
|
||||
- Implement `generateCreateFieldsFromAttributes()` using `attributeTypeMapper` utilities
|
||||
- Implement `ensureAttributesLoaded()` for EditActionButton
|
||||
- Implement optimistic updates (`removeOptimistically`, `updateOptimistically`)
|
||||
- Return pagination info, attributes, permissions, and all required functions
|
||||
- `useRbacRoleOperations()` - Operations hook for CRUD
|
||||
- `handleRoleDelete()` - Delete with loading state tracking
|
||||
- `handleRoleCreate()` - Create with error handling
|
||||
- `handleRoleUpdate()` - Update with error handling
|
||||
- Track loading states in Sets (deletingRoles, editingRoles, creatingRole)
|
||||
- Return error states (deleteError, createError, updateError)
|
||||
|
||||
### 3. Update Page Configuration: `frontend_nyla/src/core/PageManager/data/pages/admin/rbac-role.ts`
|
||||
- Follow the exact structure from `mandates.ts`
|
||||
- Import `FaPlus` from `react-icons/fa` for the create button icon
|
||||
- Create `createRbacRolesHook()` factory function that:
|
||||
- Uses `useRbacRoles()` and `useRbacRoleOperations()`
|
||||
- Converts attributes to columns using `attributesToColumns()` helper
|
||||
- Implements `handleDeleteSingle` and `handleDeleteMultiple` callbacks
|
||||
- Returns all required data for FormGeneratorTable
|
||||
- Update `rbacRolePageData`:
|
||||
- Add header button with `FaPlus` icon for creating roles (following mandates.ts pattern):
|
||||
```typescript
|
||||
headerButtons: [
|
||||
{
|
||||
id: 'add-role',
|
||||
label: 'admin.rbac-role.new_button',
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
icon: FaPlus,
|
||||
formConfig: {
|
||||
fields: [], // Empty array - fields will be generated dynamically from attributes
|
||||
popupTitle: 'admin.rbac-role.modal.create.title',
|
||||
popupSize: 'medium',
|
||||
createOperationName: 'handleRoleCreate',
|
||||
successMessage: 'admin.rbac-role.create.success',
|
||||
errorMessage: 'admin.rbac-role.create.error'
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
- Add table content section with:
|
||||
- `hookFactory: createRbacRolesHook`
|
||||
- Action buttons: edit and delete (following mandates pattern)
|
||||
- Configure edit button with `fetchItemFunctionName: 'fetchRoleById'`
|
||||
- Configure delete button with proper operation names
|
||||
- Add permission-based disabled logic
|
||||
- Keep existing privilege checker (sysadmin only)
|
||||
|
||||
### 4. Update Translations: All three locale files
|
||||
- **German (`frontend_nyla/src/locales/de.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Rolle hinzufügen'`
|
||||
- `'admin.rbac-role.action.edit': 'Bearbeiten'`
|
||||
- `'admin.rbac-role.action.delete': 'Löschen'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Neue Rolle erstellen'`
|
||||
- `'admin.rbac-role.create.success': 'Rolle erfolgreich erstellt'`
|
||||
- `'admin.rbac-role.create.error': 'Fehler beim Erstellen der Rolle'`
|
||||
|
||||
- **English (`frontend_nyla/src/locales/en.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Add Role'`
|
||||
- `'admin.rbac-role.action.edit': 'Edit'`
|
||||
- `'admin.rbac-role.action.delete': 'Delete'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Create New Role'`
|
||||
- `'admin.rbac-role.create.success': 'Role created successfully'`
|
||||
- `'admin.rbac-role.create.error': 'Error creating role'`
|
||||
|
||||
- **French (`frontend_nyla/src/locales/fr.ts`)**: Add missing translations after line 756:
|
||||
- `'admin.rbac-role.new_button': 'Ajouter un rôle'`
|
||||
- `'admin.rbac-role.action.edit': 'Modifier'`
|
||||
- `'admin.rbac-role.action.delete': 'Supprimer'`
|
||||
- `'admin.rbac-role.modal.create.title': 'Créer un nouveau rôle'`
|
||||
- `'admin.rbac-role.create.success': 'Rôle créé avec succès'`
|
||||
- `'admin.rbac-role.create.error': 'Erreur lors de la création du rôle'`
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### API File Structure
|
||||
- Use `ApiRequestFunction` type from `useApi`
|
||||
- Support pagination parameters (page, pageSize, sort, filters, search)
|
||||
- Handle both paginated and non-paginated responses
|
||||
- Use `/api/rbac/roles` as base URL
|
||||
- Use `/api/attributes/Role` for attributes endpoint
|
||||
|
||||
### Hook Pattern
|
||||
- Use `useApiRequest` hook for API calls
|
||||
- Use `usePermissions` hook for permission checking
|
||||
- Use `getUserDataCache()` to check authentication before fetching
|
||||
- Implement attribute type mapping using utilities from `attributeTypeMapper.ts`:
|
||||
- `isCheckboxType()`, `isSelectType()`, `isMultiselectType()`, `isDateTimeType()`, `isTextareaType()`
|
||||
- Filter out non-editable fields (id, readonly fields, etc.)
|
||||
- Handle options arrays and option references
|
||||
|
||||
### Page Configuration Pattern
|
||||
- Use `attributesToColumns()` helper to convert attributes to column config
|
||||
- Disable filtering for date/timestamp fields using `isDateTimeType()`
|
||||
- Configure action buttons with proper field mappings and operation names
|
||||
- Use permission-based disabled logic for buttons
|
||||
- Set `entityType: 'Role'` for EditActionButton
|
||||
- Add header button using CreateButton component pattern (via formConfig in headerButtons)
|
||||
|
||||
## Key Dependencies
|
||||
- `useApiRequest` from `hooks/useApi`
|
||||
- `usePermissions` from `hooks/usePermissions`
|
||||
- `fetchAttributes` from `api/attributesApi`
|
||||
- `attributeTypeMapper` utilities from `utils/attributeTypeMapper`
|
||||
- `FormGeneratorTable` component
|
||||
- `EditActionButton` and `DeleteActionButton` components
|
||||
- `CreateButton` component (rendered via PageRenderer from headerButtons formConfig)
|
||||
- `FaPlus` icon from `react-icons/fa`
|
||||
|
||||
## Testing Considerations
|
||||
- Verify all API endpoints are called correctly
|
||||
- Ensure attributes are fetched from `/api/attributes/Role`
|
||||
- Verify permission checks work correctly
|
||||
- Test create, edit, delete operations
|
||||
- Verify optimistic updates work
|
||||
- Check that date/timestamp fields are not filterable
|
||||
- Verify CreateButton appears in header and opens create modal
|
||||
- Verify translations work in all three languages
|
||||
53
.forgejo/workflows/int_porta-int-ui-nyla.yml
Normal file
53
.forgejo/workflows/int_porta-int-ui-nyla.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Deploy Nyla Frontend to Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- int
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
SERVER_HOST: porta-int.poweron.swiss
|
||||
SERVER_USER: ubuntu
|
||||
APP_DIR: /srv/nyla/current
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
|
||||
set -e
|
||||
cd ${{ env.APP_DIR }}
|
||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
|
||||
git fetch origin int
|
||||
git reset --hard origin/int
|
||||
cp config/env-int.env .env
|
||||
rm -f config/env-*.env
|
||||
npm ci
|
||||
npm run build:int
|
||||
"
|
||||
|
||||
- name: Health Check
|
||||
run: |
|
||||
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
|
||||
https://${{ env.SERVER_HOST }}/ || echo "000")
|
||||
if [ "$HTTP_STATUS" = "200" ]; then
|
||||
echo "Health check passed! (HTTP $HTTP_STATUS)"
|
||||
else
|
||||
echo "Health check returned HTTP $HTTP_STATUS"
|
||||
fi
|
||||
53
.forgejo/workflows/main_porta-main-ui-nyla.yml
Normal file
53
.forgejo/workflows/main_porta-main-ui-nyla.yml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Deploy Nyla Frontend to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
SERVER_HOST: porta.poweron.swiss
|
||||
SERVER_USER: ubuntu
|
||||
APP_DIR: /srv/nyla/current
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
printf '%s\n' "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
||||
|
||||
- name: Deploy
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key ${{ env.SERVER_USER }}@${{ env.SERVER_HOST }} "
|
||||
set -e
|
||||
cd ${{ env.APP_DIR }}
|
||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/ui-nyla.git
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
cp config/env-prod.env .env
|
||||
rm -f config/env-*.env
|
||||
npm ci
|
||||
npm run build:prod
|
||||
"
|
||||
|
||||
- name: Health Check
|
||||
run: |
|
||||
HTTP_STATUS=$(curl -sk -o /dev/null -w "%{http_code}" \
|
||||
https://${{ env.SERVER_HOST }}/ || echo "000")
|
||||
if [ "$HTTP_STATUS" = "200" ]; then
|
||||
echo "Health check passed! (HTTP $HTTP_STATUS)"
|
||||
else
|
||||
echo "Health check returned HTTP $HTTP_STATUS"
|
||||
fi
|
||||
71
.github/workflows/poweron_nyla_int.yml
vendored
71
.github/workflows/poweron_nyla_int.yml
vendored
|
|
@ -1,71 +0,0 @@
|
|||
name: Deploy Nyla Frontend to Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- int
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Copy integration environment file
|
||||
run: |
|
||||
cp config/.env.int .env
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm install express
|
||||
|
||||
- name: Build React app for integration
|
||||
run: npm run build:int
|
||||
|
||||
- name: Prepare deployment package
|
||||
run: |
|
||||
# Create deployment package with build files and necessary configs
|
||||
mkdir deploy
|
||||
cp -r dist/* deploy/
|
||||
# Create a simple server.js for serving the app
|
||||
echo "const express = require('express');" > deploy/server.js
|
||||
echo "const path = require('path');" >> deploy/server.js
|
||||
echo "const app = express();" >> deploy/server.js
|
||||
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
|
||||
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
|
||||
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
|
||||
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
|
||||
# Create a new package.json for deployment
|
||||
echo '{
|
||||
"name": "frontend-int",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}' > deploy/package.json
|
||||
|
||||
- name: 'Deploy to Azure Web App'
|
||||
uses: azure/webapps-deploy@v3
|
||||
with:
|
||||
app-name: 'poweron-nyla-int'
|
||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA_INT }}
|
||||
package: ./deploy
|
||||
71
.github/workflows/poweron_nyla_main.yml
vendored
71
.github/workflows/poweron_nyla_main.yml
vendored
|
|
@ -1,71 +0,0 @@
|
|||
name: Deploy Nyla Frontend to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
# Cancel in-progress runs when a new run is triggered (saves logs/storage)
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Copy production environment file
|
||||
run: |
|
||||
cp config/.env.prod .env
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm install express
|
||||
|
||||
- name: Build React app for production
|
||||
run: npm run build:prod
|
||||
|
||||
- name: Prepare deployment package
|
||||
run: |
|
||||
# Create deployment package with build files and necessary configs
|
||||
mkdir deploy
|
||||
cp -r dist/* deploy/
|
||||
# Create a simple server.js for serving the app
|
||||
echo "const express = require('express');" > deploy/server.js
|
||||
echo "const path = require('path');" >> deploy/server.js
|
||||
echo "const app = express();" >> deploy/server.js
|
||||
echo "app.use(express.static(path.join(__dirname)));" >> deploy/server.js
|
||||
echo "app.get('/*', function(req, res) { res.sendFile(path.join(__dirname, 'index.html')); });" >> deploy/server.js
|
||||
echo "const port = process.env.PORT || 8080;" >> deploy/server.js
|
||||
echo "app.listen(port, () => console.log('Server running on port', port));" >> deploy/server.js
|
||||
# Create a new package.json for deployment
|
||||
echo '{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
}' > deploy/package.json
|
||||
|
||||
- name: 'Deploy to Azure Web App'
|
||||
uses: azure/webapps-deploy@v3
|
||||
with:
|
||||
app-name: 'poweron-nyla'
|
||||
publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_POWERON_NYLA }}
|
||||
package: ./deploy
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -30,7 +30,8 @@ dist-ssr
|
|||
|
||||
.cursorignore
|
||||
|
||||
# Keep environment template files in config/
|
||||
!config/.env.dev
|
||||
!config/.env.int
|
||||
!config/.env.prod
|
||||
# Keep environment files in config/ (naming: env-<workflow>.env)
|
||||
!config/env-*.env
|
||||
|
||||
tsc-errors.txt
|
||||
scripts/i18n_missing_report.md
|
||||
31
README.md
31
README.md
|
|
@ -5,9 +5,9 @@
|
|||
```mermaid
|
||||
graph TB
|
||||
%% Environment Files
|
||||
ENV_DEV[".env.dev<br/>Development"]
|
||||
ENV_PROD[".env.prod<br/>Production"]
|
||||
ENV_INT[".env.int<br/>Integration"]
|
||||
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"]
|
||||
|
||||
%% Configuration System
|
||||
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
||||
|
|
@ -114,30 +114,25 @@ The app uses a **dual configuration system** to handle environment variables acr
|
|||
- **Used by:** Express servers and build scripts
|
||||
|
||||
### Environment Files
|
||||
- **`config/.env.dev`** - Development environment variables
|
||||
- **Why:** Separate config for local development with debug settings
|
||||
- **How:** Copied to root `.env` by `npm run dev` command
|
||||
- **Contains:** Local API URLs, debug flags, dev-specific settings
|
||||
|
||||
- **`config/.env.prod`** - Production environment variables
|
||||
- **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
|
||||
Naming convention: `env-<workflow-name>.env` — matches the GitHub Actions workflow that uses it.
|
||||
|
||||
- **`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
|
||||
- **`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).
|
||||
|
||||
### Usage
|
||||
```bash
|
||||
# Development (loads .env.dev)
|
||||
# Local development — copy env then start Vite
|
||||
cp config/env-poweron-nyla-dev.env .env
|
||||
npm run dev
|
||||
|
||||
# Production build (loads .env.prod)
|
||||
# Production build (CI copies env-poweron-nyla-prod.env → .env)
|
||||
npm run build:prod
|
||||
|
||||
# Integration build (loads .env.int)
|
||||
# Integration build (CI copies env-poweron-nyla-int.env → .env)
|
||||
npm run build:int
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
# Development Environment Configuration
|
||||
# Frontend Nyla - Development
|
||||
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL="http://localhost:8000/"
|
||||
VITE_API_TIMEOUT=10000
|
||||
|
||||
# Microsoft Entra ID Configuration
|
||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
||||
VITE_ENTRA_REDIRECT_URI=http://localhost:8000/api/msft/auth/callback/
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_NAME=PowerOn Nyla dev
|
||||
VITE_APP_VERSION=0.0.0
|
||||
VITE_APP_ENVIRONMENT=development
|
||||
|
||||
# Debug Configuration
|
||||
VITE_DEBUG=true
|
||||
VITE_LOG_LEVEL=debug
|
||||
VITE_ENABLE_CONSOLE_LOGS=true
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_ANALYTICS=false
|
||||
VITE_ENABLE_ERROR_REPORTING=false
|
||||
VITE_ENABLE_PERFORMANCE_MONITORING=false
|
||||
|
||||
# Development Server
|
||||
VITE_DEV_SERVER_PORT=5176
|
||||
VITE_DEV_SERVER_HOST=localhost
|
||||
VITE_DEV_SERVER_HTTPS=false
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
# Integration/Test Environment Configuration
|
||||
# Frontend Nyla - Integration
|
||||
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=https://gateway-int.poweron-center.net
|
||||
VITE_API_TIMEOUT=12000
|
||||
|
||||
# Microsoft Entra ID Configuration
|
||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
||||
VITE_ENTRA_REDIRECT_URI=https://gateway-int.poweron-center.net/api/msft/auth/callback/
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_NAME=Poweron Nyla int
|
||||
VITE_APP_VERSION=0.0.0
|
||||
VITE_APP_ENVIRONMENT=integration
|
||||
|
||||
# Debug Configuration
|
||||
VITE_DEBUG=true
|
||||
VITE_LOG_LEVEL=info
|
||||
VITE_ENABLE_CONSOLE_LOGS=true
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_ANALYTICS=true
|
||||
VITE_ENABLE_ERROR_REPORTING=true
|
||||
VITE_ENABLE_PERFORMANCE_MONITORING=true
|
||||
|
||||
# Test Configuration
|
||||
VITE_ENABLE_MOCK_DATA=false
|
||||
VITE_ENABLE_TEST_MODE=true
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
# Production Environment Configuration
|
||||
# Frontend Nyla - Production
|
||||
|
||||
# API Configuration
|
||||
VITE_API_BASE_URL=https://gateway-prod.poweron-center.net
|
||||
VITE_API_TIMEOUT=15000
|
||||
|
||||
# Microsoft Entra ID Configuration
|
||||
VITE_MICROSOFT_CLIENT_ID=24cd6c8a-b592-4905-a5ba-d5fa9f911154
|
||||
VITE_MICROSOFT_TENANT_ID=6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_CLIENT_SECRET=2iw8Q~jwqG1iacxHopBt5pstu6R45UC1gIQabcbD
|
||||
VITE_ENTRA_AUTHORITY=https://login.microsoftonline.com/6a51aaeb-2467-4186-9504-2a05aedc591f
|
||||
VITE_ENTRA_REDIRECT_PATH=/auth/callback/
|
||||
VITE_ENTRA_REDIRECT_URI=https://gateway-prod.poweron-center.net/api/msft/auth/callback/
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_NAME=PowerOn Nyla
|
||||
VITE_APP_VERSION=0.0.0
|
||||
VITE_APP_ENVIRONMENT=production
|
||||
|
||||
# Debug Configuration
|
||||
VITE_DEBUG=false
|
||||
VITE_LOG_LEVEL=error
|
||||
VITE_ENABLE_CONSOLE_LOGS=false
|
||||
|
||||
# Feature Flags
|
||||
VITE_ENABLE_ANALYTICS=true
|
||||
VITE_ENABLE_ERROR_REPORTING=true
|
||||
VITE_ENABLE_PERFORMANCE_MONITORING=true
|
||||
|
||||
# Security Configuration
|
||||
VITE_ENABLE_HTTPS=true
|
||||
VITE_ENABLE_CSP=true
|
||||
190
config/config.ts
190
config/config.ts
|
|
@ -1,178 +1,24 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Simple Configuration Service
|
||||
* Centralized access to environment variables with fallbacks
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// API Configuration
|
||||
export const getApiBaseUrl = (): string => {
|
||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
};
|
||||
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
|
||||
|
||||
export const getApiTimeout = (): number => {
|
||||
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
|
||||
};
|
||||
if (!_apiBaseUrl) {
|
||||
throw new Error(
|
||||
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
|
||||
);
|
||||
}
|
||||
|
||||
// App Configuration
|
||||
export const getAppName = (): string => {
|
||||
return import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||
};
|
||||
export const getApiBaseUrl = (): string => _apiBaseUrl;
|
||||
|
||||
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,
|
||||
};
|
||||
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||
|
|
|
|||
6
config/env-dev.env
Normal file
6
config/env-dev.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Environment: poweron-nyla-dev (local development)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL="http://localhost:8000"
|
||||
VITE_APP_NAME=PowerOn Nyla dev
|
||||
6
config/env-int.env
Normal file
6
config/env-int.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Environment: poweron-nyla-int (integration)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL=https://api-int.poweron.swiss
|
||||
VITE_APP_NAME=Poweron Nyla int
|
||||
6
config/env-prod.env
Normal file
6
config/env-prod.env
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Environment: poweron-nyla-prod (production)
|
||||
# Consumed by: Vite build (title) + SPA runtime (getApiBaseUrl / getAppName)
|
||||
# Auth and secrets live on the gateway — never in frontend env.
|
||||
|
||||
VITE_API_BASE_URL=https://api.poweron.swiss
|
||||
VITE_APP_NAME=PowerOn Nyla
|
||||
|
|
@ -1,12 +1,3 @@
|
|||
// Export simple configuration service
|
||||
export * from './config';
|
||||
|
||||
// Re-export commonly used functions
|
||||
export {
|
||||
getApiBaseUrl,
|
||||
getAppName,
|
||||
isDevelopment,
|
||||
isProduction,
|
||||
isDebugMode,
|
||||
config
|
||||
} from './config';
|
||||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { getApiBaseUrl, getAppName } from './config';
|
||||
|
|
|
|||
17
env.d.ts
vendored
17
env.d.ts
vendored
|
|
@ -1,15 +1,8 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
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
|
||||
}
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_APP_NAME?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,12 @@ export default tseslint.config(
|
|||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'warn',
|
||||
{
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
|
|
|||
1654
package-lock.json
generated
1654
package-lock.json
generated
File diff suppressed because it is too large
Load diff
16
package.json
16
package.json
|
|
@ -11,11 +11,13 @@
|
|||
"build:prod": "tsc -b && vite build --mode prod",
|
||||
"build:int": "tsc -b && vite build --mode int",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"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",
|
||||
|
|
@ -47,18 +49,24 @@
|
|||
},
|
||||
"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"
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,10 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p class="legal-meta" style="margin-bottom: 0.75rem; color: #64748b; font-size: 0.85rem;">Company & legal details · 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;">© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -272,8 +272,13 @@
|
|||
<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> privacy@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -283,7 +288,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@
|
|||
</div>
|
||||
|
||||
<div class="last-updated">
|
||||
<strong>Last Updated:</strong> August 2025
|
||||
<strong>Last Updated:</strong> May 2026
|
||||
</div>
|
||||
|
||||
<div class="content-section">
|
||||
|
|
@ -315,8 +315,13 @@
|
|||
<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> legal@poweron-ai.com</p>
|
||||
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -326,7 +331,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
||||
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
47
src/App.tsx
47
src/App.tsx
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* App.tsx
|
||||
*
|
||||
|
|
@ -25,7 +27,6 @@ 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';
|
||||
|
|
@ -40,11 +41,12 @@ 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, AdminDatabaseHealthPage } from './pages/admin';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
||||
import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
|
||||
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||
function App() {
|
||||
// Load saved theme preference and set app name on app mount
|
||||
|
|
@ -71,7 +73,6 @@ function App() {
|
|||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<VoiceCatalogProvider>
|
||||
<WorkflowSelectionProvider>
|
||||
|
|
@ -125,15 +126,20 @@ function App() {
|
|||
</Route>
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* AUTOMATIONS DASHBOARD */}
|
||||
{/* WORKFLOW AUTOMATION (System-Komponente) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
||||
<Route path="workflow-automation" element={<WorkflowAutomationPage />} />
|
||||
|
||||
{/* ============================================== */}
|
||||
{/* RAG INVENTORY */}
|
||||
{/* ============================================== */}
|
||||
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
||||
|
||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||
<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 */}
|
||||
|
|
@ -147,8 +153,7 @@ 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="documents" element={<FeatureViewPage view="documents" />} />
|
||||
<Route path="positions" element={<FeatureViewPage view="positions" />} />
|
||||
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
|
||||
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||
|
|
@ -158,8 +163,7 @@ 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="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
|
||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||
|
|
@ -169,24 +173,26 @@ function App() {
|
|||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||
|
||||
{/* Workspace + Automation2 Editor */}
|
||||
{/* Workspace 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="coaching" element={<FeatureViewPage view="coaching" />} />
|
||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
||||
<Route path="session" element={<FeatureViewPage view="session" />} />
|
||||
|
||||
{/* Redmine Feature Views */}
|
||||
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||
<Route path="browser" element={<FeatureViewPage view="browser" />} />
|
||||
|
||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||
|
|
@ -215,7 +221,7 @@ function App() {
|
|||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||
<Route path="logs" element={<AdminLogsPage />} />
|
||||
<Route path="languages" element={null} />
|
||||
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
|
||||
<Route path="database-health" element={null} />
|
||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||
|
|
@ -235,7 +241,6 @@ function App() {
|
|||
</WorkflowSelectionProvider>
|
||||
</VoiceCatalogProvider>
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
75
src/api.ts
75
src/api.ts
|
|
@ -1,25 +1,10 @@
|
|||
// 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/...
|
||||
|
|
@ -44,45 +29,25 @@ 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: getApiBaseUrl(),
|
||||
withCredentials: true
|
||||
baseURL: _baseUrl,
|
||||
withCredentials: true,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
||||
// Add a request interceptor to add the auth token, context headers
|
||||
api.interceptors.request.use(
|
||||
async (config) => {
|
||||
// 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
|
||||
// Add auth token if available (otherwise httpOnly cookies are used automatically)
|
||||
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
|
||||
|
|
@ -92,6 +57,20 @@ 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();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||
|
||||
export type { AttributeType };
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
|
|
@ -7,7 +12,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
|||
export interface AttributeDefinition {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
|
||||
type: AttributeType;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
searchable?: boolean;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
|
||||
|
|
@ -12,14 +14,30 @@ export interface LoginRequest {
|
|||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
type: 'local_auth_success';
|
||||
type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
|
||||
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;
|
||||
|
|
@ -316,3 +334,36 @@ 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -29,13 +31,36 @@ export interface BillingTransaction {
|
|||
aicoreProvider?: string;
|
||||
aicoreModel?: string;
|
||||
createdByUserId?: string;
|
||||
createdAt?: string;
|
||||
sysCreatedAt?: 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;
|
||||
|
|
@ -56,8 +81,12 @@ export interface BillingSettingsUpdate {
|
|||
rechargeMaxPerMonth?: number;
|
||||
}
|
||||
|
||||
export type BillingBucketSize = 'day' | 'month' | 'year';
|
||||
|
||||
export interface UsageReport {
|
||||
period: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
totalCost: number;
|
||||
transactionCount: number;
|
||||
costByProvider: Record<string, number>;
|
||||
|
|
@ -65,6 +94,12 @@ export interface UsageReport {
|
|||
costByFeature: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface StatisticsRangeRequest {
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
bucketSize: BillingBucketSize;
|
||||
}
|
||||
|
||||
export interface AccountSummary {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
|
|
@ -125,7 +160,31 @@ export async function fetchBalanceForMandate(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch transaction history
|
||||
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
||||
* Endpoint: GET /api/billing/transactions?pagination=...
|
||||
*/
|
||||
export async function fetchTransactionsPaginated(
|
||||
request: ApiRequestFunction,
|
||||
params?: BillingTransactionsPaginationParams
|
||||
): Promise<BillingTransactionsPaginatedResponse> {
|
||||
const paginationObj: Record<string, unknown> = {};
|
||||
if (params?.page !== undefined) paginationObj.page = params.page;
|
||||
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||
if (params?.sort?.length) paginationObj.sort = params.sort;
|
||||
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
||||
if (params?.search) paginationObj.search = params.search;
|
||||
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
||||
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||
|
||||
return await request({
|
||||
url: '/api/billing/transactions',
|
||||
method: 'get',
|
||||
params: { pagination: JSON.stringify(paginationObj) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch transaction history (legacy array window)
|
||||
* Endpoint: GET /api/billing/transactions
|
||||
*/
|
||||
export async function fetchTransactions(
|
||||
|
|
@ -141,24 +200,21 @@ export async function fetchTransactions(
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch usage statistics
|
||||
* Endpoint: GET /api/billing/statistics/{period}
|
||||
* Fetch usage statistics for an explicit date range.
|
||||
* Endpoint: GET /api/billing/statistics
|
||||
*/
|
||||
export async function fetchStatistics(
|
||||
request: ApiRequestFunction,
|
||||
period: 'day' | 'month' | 'year',
|
||||
year: number,
|
||||
month?: number
|
||||
range: StatisticsRangeRequest
|
||||
): Promise<UsageReport> {
|
||||
const params: Record<string, any> = { year };
|
||||
if (month !== undefined) {
|
||||
params.month = month;
|
||||
}
|
||||
|
||||
return await request({
|
||||
url: `/api/billing/statistics/${period}`,
|
||||
url: '/api/billing/statistics',
|
||||
method: 'get',
|
||||
params
|
||||
params: {
|
||||
dateFrom: range.dateFrom,
|
||||
dateTo: range.dateTo,
|
||||
bucketSize: range.bucketSize,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -225,6 +281,19 @@ export async function addCreditAdmin(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the server-side allow-list of CHF top-up amounts
|
||||
* Endpoint: GET /api/billing/checkout/amounts
|
||||
*/
|
||||
export async function fetchCheckoutAmounts(
|
||||
request: ApiRequestFunction
|
||||
): Promise<number[]> {
|
||||
return await request({
|
||||
url: '/api/billing/checkout/amounts',
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Stripe Checkout Session for credit top-up
|
||||
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
||||
|
|
|
|||
|
|
@ -1,329 +0,0 @@
|
|||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface UserInputRequest {
|
||||
input: string;
|
||||
workflowId?: string;
|
||||
files?: Array<{ id: string; name: string }>;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ChatbotWorkflow {
|
||||
id: string;
|
||||
mandateId?: string; // Optional - not in ChatbotConversation
|
||||
featureInstanceId?: string; // From ChatbotConversation
|
||||
status: string;
|
||||
name?: string;
|
||||
currentRound?: number;
|
||||
currentTask?: number;
|
||||
currentAction?: number;
|
||||
startedAt?: number;
|
||||
lastActivity?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface StartChatbotRequest {
|
||||
prompt: string;
|
||||
listFileId?: string[];
|
||||
userLanguage?: string;
|
||||
workflowId?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface StartChatbotResponse extends ChatbotWorkflow {
|
||||
// Workflow object returned from start endpoint
|
||||
}
|
||||
|
||||
export interface ChatDataItem {
|
||||
type: 'message' | 'log' | 'stat' | 'document' | 'stopped' | 'status' | 'chunk';
|
||||
createdAt?: number;
|
||||
item?: Message | any;
|
||||
label?: string; // For status events
|
||||
content?: string; // For chunk events (token-by-token streaming)
|
||||
}
|
||||
|
||||
// Type for the request function passed to API functions
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
// Type for SSE event handler
|
||||
export type SSEEventHandler = (item: ChatDataItem) => void;
|
||||
|
||||
// ============================================================================
|
||||
// API REQUEST FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Start a new chatbot workflow or continue an existing one with SSE streaming
|
||||
* Endpoint: POST /api/chatbot/{instanceId}/start/stream
|
||||
*
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param requestBody - Request body with prompt and optional workflowId
|
||||
* @param onEvent - Callback function called for each SSE event
|
||||
* @param onError - Optional error callback
|
||||
* @param onComplete - Optional completion callback
|
||||
* @returns Promise that resolves when stream completes
|
||||
*/
|
||||
export async function startChatbotStreamApi(
|
||||
instanceId: string,
|
||||
requestBody: StartChatbotRequest,
|
||||
onEvent: SSEEventHandler,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Prepare request body
|
||||
console.log('[startChatbotStreamApi] instanceId:', instanceId);
|
||||
console.log('[startChatbotStreamApi] requestBody received:', JSON.stringify(requestBody, null, 2));
|
||||
|
||||
const body: any = {
|
||||
prompt: requestBody.prompt,
|
||||
...(requestBody.listFileId && requestBody.listFileId.length > 0 && { listFileId: requestBody.listFileId }),
|
||||
...(requestBody.userLanguage && { userLanguage: requestBody.userLanguage }),
|
||||
...(requestBody.metadata && { metadata: requestBody.metadata })
|
||||
};
|
||||
|
||||
console.log('[startChatbotStreamApi] body being sent:', JSON.stringify(body, null, 2));
|
||||
|
||||
// Add workflowId to query params if provided
|
||||
const url = requestBody.workflowId
|
||||
? `/api/chatbot/${instanceId}/start/stream?workflowId=${encodeURIComponent(requestBody.workflowId)}`
|
||||
: `/api/chatbot/${instanceId}/start/stream`;
|
||||
|
||||
// Get base URL from api instance
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const fullURL = baseURL + url;
|
||||
|
||||
// Prepare headers with authentication and CSRF token
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
if (authToken) {
|
||||
headers['Authorization'] = `Bearer ${authToken}`;
|
||||
}
|
||||
|
||||
// Add CSRF token for POST requests
|
||||
if (!getCSRFToken()) {
|
||||
generateAndStoreCSRFToken();
|
||||
}
|
||||
addCSRFTokenToHeaders(headers);
|
||||
|
||||
// Use fetch for SSE streaming (POST with body)
|
||||
const response = await fetch(fullURL, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
credentials: 'include' // Include cookies for authentication
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode chunk and add to buffer
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete SSE messages
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6); // Remove 'data: ' prefix
|
||||
if (jsonStr.trim()) {
|
||||
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||
console.log('[SSE] Received event:', item.type, item);
|
||||
onEvent(item);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE event:', line, parseError);
|
||||
}
|
||||
} else if (line.startsWith(':')) {
|
||||
// Comment/keepalive line, ignore
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining buffer content
|
||||
if (buffer.trim()) {
|
||||
const lines = buffer.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
const item: ChatDataItem = JSON.parse(jsonStr);
|
||||
onEvent(item);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse SSE event:', line, parseError);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error in startChatbotStreamApi:', error);
|
||||
if (onError) {
|
||||
onError(error instanceof Error ? error : new Error(String(error)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a running chatbot workflow
|
||||
* Endpoint: POST /api/chatbot/{instanceId}/stop/{workflowId}
|
||||
*/
|
||||
export async function stopChatbotApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<ChatbotWorkflow> {
|
||||
console.log('[stopChatbotApi] Calling stop endpoint:', `/api/chatbot/${instanceId}/stop/${workflowId}`, { instanceId, workflowId });
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/stop/${workflowId}`,
|
||||
method: 'post'
|
||||
});
|
||||
|
||||
console.log('[stopChatbotApi] Stop response:', data);
|
||||
return data as ChatbotWorkflow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chatbot threads/workflows
|
||||
* Endpoint: GET /api/chatbot/{instanceId}/threads
|
||||
*/
|
||||
export async function getChatbotThreadsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
pagination?: { page?: number; pageSize?: number }
|
||||
): Promise<{ items: ChatbotWorkflow[]; metadata: any }> {
|
||||
const paginationParam = pagination ? JSON.stringify(pagination) : undefined;
|
||||
const requestParams = paginationParam
|
||||
? { pagination: paginationParam }
|
||||
: undefined;
|
||||
|
||||
console.log(`[getChatbotThreadsApi] instanceId: ${instanceId}, params:`, requestParams);
|
||||
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/threads`,
|
||||
method: 'get',
|
||||
params: requestParams
|
||||
}) as any;
|
||||
|
||||
console.log(`[getChatbotThreadsApi] Full response:`, JSON.stringify(data, null, 2));
|
||||
console.log(`[getChatbotThreadsApi] Response structure:`, {
|
||||
hasItems: !!data.items,
|
||||
itemsLength: Array.isArray(data.items) ? data.items.length : 'not an array',
|
||||
hasMetadata: !!data.metadata,
|
||||
metadataKeys: data.metadata ? Object.keys(data.metadata) : []
|
||||
});
|
||||
|
||||
return {
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
metadata: data.pagination ?? data.metadata ?? {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific chatbot thread/workflow with its chat data
|
||||
* Endpoint: GET /api/chatbot/{instanceId}/threads?workflowId={id}
|
||||
*
|
||||
* Backend returns: { workflow: ChatbotWorkflow, chatData: { items: ChatDataItem[] } }
|
||||
*
|
||||
* @param request - API request function
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param workflowId - ID of the workflow to fetch
|
||||
* @returns Object containing workflow details and chatData with items array
|
||||
*/
|
||||
export async function getChatbotThreadApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<{ workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } }> {
|
||||
console.log(`[getChatbotThreadApi] instanceId: ${instanceId}, workflowId: ${workflowId}`);
|
||||
|
||||
const data = await request({
|
||||
url: `/api/chatbot/${instanceId}/threads`,
|
||||
method: 'get',
|
||||
params: { workflowId }
|
||||
}) as { workflow: ChatbotWorkflow; chatData: { items: ChatDataItem[] } };
|
||||
|
||||
console.log(`[getChatbotThreadApi] Full response for workflowId ${workflowId}:`, JSON.stringify(data, null, 2));
|
||||
console.log(`[getChatbotThreadApi] Response structure:`, {
|
||||
hasWorkflow: !!data.workflow,
|
||||
workflowKeys: data.workflow ? Object.keys(data.workflow) : [],
|
||||
hasChatData: !!data.chatData,
|
||||
hasItems: !!data.chatData?.items,
|
||||
chatDataKeys: data.chatData ? Object.keys(data.chatData) : [],
|
||||
itemsLength: Array.isArray(data.chatData?.items) ? data.chatData.items.length : 'not an array',
|
||||
chatDataTypes: Array.isArray(data.chatData?.items) ? data.chatData.items.map((item: ChatDataItem) => item?.type).filter(Boolean) : []
|
||||
});
|
||||
|
||||
return {
|
||||
workflow: data.workflow,
|
||||
chatData: data.chatData || { items: [] }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chatbot workflow
|
||||
* Endpoint: DELETE /api/chatbot/{instanceId}/{workflowId}
|
||||
*
|
||||
* @param request - API request function
|
||||
* @param instanceId - Feature Instance ID
|
||||
* @param workflowId - ID of the workflow to delete
|
||||
* @returns Success status
|
||||
*/
|
||||
export async function deleteChatbotWorkflowApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await request({
|
||||
url: `/api/chatbot/${instanceId}/${workflowId}`,
|
||||
method: 'delete'
|
||||
});
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting chatbot workflow:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
135
src/api/clickupApi.ts
Normal file
135
src/api/clickupApi.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ClickUp API — ClickUp-specific functions for the workflow automation flow editor.
|
||||
*
|
||||
* Extracted from the legacy workflowApi.ts re-export shim so each integration
|
||||
* lives in its own module.
|
||||
*/
|
||||
|
||||
import type { ApiRequestFunction } from './workflowAutomationApi';
|
||||
|
||||
function _encodedConnectionId(connectionId: string): string {
|
||||
return encodeURIComponent(connectionId);
|
||||
}
|
||||
|
||||
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
|
||||
export async function fetchClickupTask(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
taskId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${_encodedConnectionId(connectionId)}/tasks/${encodeURIComponent(taskId)}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
|
||||
export async function fetchClickupList(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
|
||||
export async function fetchClickupTeam(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
teamId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${_encodedConnectionId(connectionId)}/teams/${encodeURIComponent(teamId)}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
|
||||
export async function fetchClickupListFields(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/fields`,
|
||||
method: 'get',
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
|
||||
export interface ClickupListTaskItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function fetchClickupListTasks(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string,
|
||||
options?: { page?: number; includeClosed?: boolean }
|
||||
): Promise<
|
||||
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
|
||||
> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${_encodedConnectionId(connectionId)}/lists/${encodeURIComponent(listId)}/tasks`,
|
||||
method: 'get',
|
||||
params: {
|
||||
page: options?.page ?? 0,
|
||||
include_closed: options?.includeClosed ?? false,
|
||||
},
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as {
|
||||
tasks?: ClickupListTaskItem[];
|
||||
last_page?: boolean;
|
||||
} & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe". */
|
||||
export async function loadClickupListTasksForDropdown(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const acc: Array<{ id: string; name: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const maxPages = 12;
|
||||
const pageSizeHint = 100;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const data = await fetchClickupListTasks(request, connectionId, listId, {
|
||||
page,
|
||||
includeClosed: false,
|
||||
});
|
||||
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
|
||||
const err = (data as { error?: unknown }).error;
|
||||
const body = (data as { body?: string }).body;
|
||||
throw new Error(
|
||||
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
|
||||
);
|
||||
}
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
for (const t of tasks) {
|
||||
const id = t?.id != null ? String(t.id) : '';
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
acc.push({ id, name: String(t.name ?? id) });
|
||||
}
|
||||
const rawLast = (data as Record<string, unknown>).last_page;
|
||||
const last =
|
||||
rawLast === true ||
|
||||
rawLast === 'true' ||
|
||||
tasks.length === 0 ||
|
||||
tasks.length < pageSizeHint;
|
||||
if (last) break;
|
||||
}
|
||||
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return acc;
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
|
@ -109,8 +111,8 @@ export interface CoachingUserProfile {
|
|||
}
|
||||
|
||||
export interface DashboardData {
|
||||
totalContexts: number;
|
||||
activeContexts: number;
|
||||
totalModules: number;
|
||||
activeModules: number;
|
||||
totalSessions: number;
|
||||
totalMinutes: number;
|
||||
streakDays: number;
|
||||
|
|
@ -122,7 +124,11 @@ export interface DashboardData {
|
|||
goalProgress?: number;
|
||||
badges?: CoachingBadge[];
|
||||
level?: { number: number; label: string; totalSessions: number };
|
||||
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
/** @deprecated Use totalModules/activeModules/modules instead */
|
||||
totalContexts?: number;
|
||||
activeContexts?: number;
|
||||
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||
}
|
||||
|
||||
export interface SSEEvent {
|
||||
|
|
@ -133,31 +139,73 @@ 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;
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context API
|
||||
// Module API (TrainingModule — replaces Context API)
|
||||
// ============================================================================
|
||||
|
||||
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
|
||||
}): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
|
||||
return data.sessions || [];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Context / Module API (uses /modules/ endpoints)
|
||||
// ============================================================================
|
||||
|
||||
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' });
|
||||
return data.contexts || [];
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||
return data.modules || [];
|
||||
}
|
||||
|
||||
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}/contexts`, method: 'post', data: body });
|
||||
return data.context;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
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}/contexts/${contextId}`,
|
||||
url: `/api/commcoach/${instanceId}/modules/${contextId}`,
|
||||
method: 'get',
|
||||
params: { _t: Date.now() },
|
||||
});
|
||||
const ctx = data?.context ?? data;
|
||||
const ctx = data?.module ?? data;
|
||||
return {
|
||||
context: ctx,
|
||||
tasks: data?.tasks ?? [],
|
||||
|
|
@ -167,22 +215,22 @@ export async function getContextDetailApi(request: ApiRequestFunction, instanceI
|
|||
}
|
||||
|
||||
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body });
|
||||
return data.context;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' });
|
||||
await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' });
|
||||
return data.context;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' });
|
||||
return data.context;
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
|
||||
return data.module;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -192,7 +240,7 @@ export async function activateContextApi(request: ApiRequestFunction, instanceId
|
|||
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
||||
}> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
|
||||
return data;
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +255,7 @@ export async function startSessionStreamApi(
|
|||
try {
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
|
||||
const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const authToken = localStorage.getItem('authToken');
|
||||
|
|
@ -243,14 +291,11 @@ export async function startSessionStreamApi(
|
|||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
const event: SSEEvent = JSON.parse(jsonStr);
|
||||
onEvent(event);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -348,14 +393,11 @@ export async function sendMessageStreamApi(
|
|||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
const event: SSEEvent = JSON.parse(jsonStr);
|
||||
onEvent(event);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -424,10 +466,12 @@ export async function sendAudioStreamApi(
|
|||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr));
|
||||
} catch { /* skip */ }
|
||||
const jsonStr = line.slice(6);
|
||||
if (jsonStr.trim()) {
|
||||
let event: SSEEvent;
|
||||
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||
onEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -446,14 +490,14 @@ export async function sendAudioStreamApi(
|
|||
// ============================================================================
|
||||
|
||||
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'get' });
|
||||
return data.tasks || [];
|
||||
}
|
||||
|
||||
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}/contexts/${contextId}/tasks`, method: 'post', data: body });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
|
||||
return data.task;
|
||||
}
|
||||
|
||||
|
|
@ -500,7 +544,14 @@ 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.personas || [];
|
||||
return data.items || data.personas || [];
|
||||
}
|
||||
|
||||
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
|
||||
const queryParams: Record<string, string> = {};
|
||||
if (params) queryParams.pagination = JSON.stringify(params);
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||
|
|
@ -510,10 +561,31 @@ 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)
|
||||
// ============================================================================
|
||||
|
|
@ -529,7 +601,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}/contexts/${contextId}/export?format=${format}`;
|
||||
return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
|
||||
}
|
||||
|
||||
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||
|
|
@ -544,6 +616,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma
|
|||
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
||||
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||
}>>> {
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
|
||||
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
|
||||
return data.history || {};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,25 @@
|
|||
// 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';
|
||||
authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||
externalId: string;
|
||||
externalUsername: string;
|
||||
externalEmail?: string;
|
||||
|
|
@ -15,6 +27,8 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -37,6 +51,22 @@ 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> {
|
||||
|
|
@ -47,17 +77,21 @@ 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';
|
||||
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
|
||||
authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
|
||||
type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // 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;
|
||||
|
|
@ -103,6 +137,8 @@ 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);
|
||||
|
|
@ -136,14 +172,20 @@ 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
|
||||
connectionId: string,
|
||||
reauth: boolean = false
|
||||
): Promise<ConnectResponse> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/connect`,
|
||||
method: 'post'
|
||||
method: 'post',
|
||||
data: reauth ? { reauth: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -221,3 +263,235 @@ export async function refreshGoogleToken(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
|
||||
* UserConnection. The backend validates the token via /1/profile and stores it
|
||||
* as the connection's data-access bearer token.
|
||||
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
|
||||
*/
|
||||
export async function submitInfomaniakToken(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
token: string
|
||||
): Promise<{
|
||||
id: string;
|
||||
status: string;
|
||||
type: string;
|
||||
externalUsername: string;
|
||||
externalEmail?: string | null;
|
||||
lastChecked: number;
|
||||
}> {
|
||||
return await request({
|
||||
url: `/api/infomaniak/connections/${connectionId}/token`,
|
||||
method: 'post',
|
||||
data: { token }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RAG KNOWLEDGE CONSENT & CONTROL
|
||||
// ============================================================================
|
||||
|
||||
export async function patchKnowledgeConsent(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
enabled: boolean
|
||||
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-consent`,
|
||||
method: 'patch',
|
||||
data: { enabled }
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchKnowledgePreferences(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
preferences: KnowledgePreferences
|
||||
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-preferences`,
|
||||
method: 'patch',
|
||||
data: { preferences }
|
||||
});
|
||||
}
|
||||
|
||||
export async function postKnowledgeStop(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string
|
||||
): Promise<{ connectionId: string; cancelled: number }> {
|
||||
return await request({
|
||||
url: `/api/connections/${connectionId}/knowledge-stop`,
|
||||
method: 'post'
|
||||
});
|
||||
}
|
||||
|
||||
export interface RagLimits {
|
||||
maxItems?: number;
|
||||
maxBytes?: number;
|
||||
maxFileSize?: number;
|
||||
maxDepth?: number;
|
||||
// ClickUp variant
|
||||
maxTasks?: number;
|
||||
maxWorkspaces?: number;
|
||||
maxListsPerWorkspace?: number;
|
||||
}
|
||||
|
||||
export interface DataSourceSettings {
|
||||
ragLimits?: RagLimits;
|
||||
}
|
||||
|
||||
export interface CostEstimate {
|
||||
estimatedTokens: number;
|
||||
estimatedChf: number;
|
||||
basis: {
|
||||
kind: string;
|
||||
limits: Record<string, number>;
|
||||
assumptions: Record<string, any>;
|
||||
notes: string;
|
||||
};
|
||||
sourceId?: string;
|
||||
}
|
||||
|
||||
export async function patchDataSourceSettings(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string,
|
||||
settings: DataSourceSettings
|
||||
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/settings`,
|
||||
method: 'patch',
|
||||
data: { settings }
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDataSourceCostEstimate(
|
||||
request: ApiRequestFunction,
|
||||
dataSourceId: string
|
||||
): Promise<CostEstimate> {
|
||||
return await request({
|
||||
url: `/api/datasources/${dataSourceId}/cost-estimate`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
|
||||
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
|
||||
// `UdbSourcesProvider` and the wiki UDB reference page.
|
||||
|
||||
// ============================================================================
|
||||
// RAG INVENTORY
|
||||
// ============================================================================
|
||||
|
||||
export interface RagDataSourceDto {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
sourceType: string;
|
||||
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||
ragIndexEnabled: boolean | null;
|
||||
neutralize: boolean | null;
|
||||
lastIndexed: number | null;
|
||||
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||
fileCount: number;
|
||||
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
|
||||
chunkCount: number;
|
||||
}
|
||||
|
||||
export interface RagConnectionDto {
|
||||
id: string;
|
||||
authority: string;
|
||||
externalEmail: string;
|
||||
knowledgeIngestionEnabled: boolean;
|
||||
preferences: KnowledgePreferences;
|
||||
dataSources: RagDataSourceDto[];
|
||||
totalFiles: number;
|
||||
totalChunks: number;
|
||||
runningJobs: {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
/** Already translated server-side. */
|
||||
progressMessage: string;
|
||||
}[];
|
||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
finishedAt: number | null;
|
||||
indexed: number;
|
||||
skippedDuplicate: number;
|
||||
skippedPolicy: number;
|
||||
failed: number;
|
||||
durationMs: number;
|
||||
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
|
||||
stoppedAtLimit?: string | null;
|
||||
/** Effective limits used by the walker, for showing the value next to the limit name. */
|
||||
limits?: Record<string, number>;
|
||||
bytesProcessed?: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RagFeatureDataSourceDto {
|
||||
id: string;
|
||||
label: string;
|
||||
tableName: string;
|
||||
featureCode: string;
|
||||
ragIndexEnabled: boolean;
|
||||
}
|
||||
|
||||
export interface RagFeatureInstanceDto {
|
||||
featureInstanceId: string;
|
||||
featureCode: string;
|
||||
label: string;
|
||||
mandateId: string;
|
||||
fileCount: number;
|
||||
chunkCount: number;
|
||||
statusCounts: Record<string, number>;
|
||||
dataSources: RagFeatureDataSourceDto[];
|
||||
ragEnabled: boolean;
|
||||
runningJobs?: {
|
||||
jobId: string;
|
||||
progress: number;
|
||||
progressMessage: string;
|
||||
}[];
|
||||
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||
lastSuccess?: {
|
||||
jobId: string;
|
||||
finishedAt: number | null;
|
||||
indexed: number;
|
||||
skippedDuplicate: number;
|
||||
failed: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface RagInventoryDto {
|
||||
connections: RagConnectionDto[];
|
||||
featureInstances?: RagFeatureInstanceDto[];
|
||||
totals: { files: number; chunks: number; bytes?: number };
|
||||
}
|
||||
|
||||
export interface RagActiveJobDto {
|
||||
jobId: string;
|
||||
connectionId: string;
|
||||
connectionLabel?: string;
|
||||
jobType: string;
|
||||
progress: number | null;
|
||||
/** Already translated server-side. */
|
||||
progressMessage: string;
|
||||
}
|
||||
|
||||
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||
return await request({ url: '/api/rag/inventory/me', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
|
||||
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
|
||||
}
|
||||
|
||||
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
|
||||
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Features API
|
||||
*
|
||||
|
|
@ -14,8 +16,6 @@ import type {
|
|||
InstancePermissions,
|
||||
AccessLevel,
|
||||
} from '../types/mandate';
|
||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA (Temporär bis Backend bereit)
|
||||
// =============================================================================
|
||||
|
|
@ -172,56 +172,11 @@ 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);
|
||||
|
|
@ -239,7 +194,6 @@ 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: [] },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -34,6 +36,9 @@ 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> {
|
||||
|
|
@ -44,6 +49,8 @@ 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
|
||||
|
|
@ -103,7 +110,10 @@ 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);
|
||||
}
|
||||
|
|
@ -186,110 +196,72 @@ 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 }
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FOLDER API FUNCTIONS
|
||||
// GROUP BULK API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
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(
|
||||
/** Patch scope for all files in a group (recursive) */
|
||||
export async function patchGroupScope(
|
||||
request: ApiRequestFunction,
|
||||
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/folders',
|
||||
method: 'post',
|
||||
data: { name, parentId: parentId || null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameFolder(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
name: string
|
||||
groupId: string,
|
||||
scope: string
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}`,
|
||||
method: 'put',
|
||||
data: { name },
|
||||
url: `/api/files/groups/${groupId}/scope`,
|
||||
method: 'patch',
|
||||
data: { scope },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteFolderApi(
|
||||
/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
|
||||
export async function patchGroupNeutralize(
|
||||
request: ApiRequestFunction,
|
||||
folderId: string,
|
||||
recursive: boolean = false
|
||||
groupId: string,
|
||||
neutralize: boolean
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/folders/${folderId}`,
|
||||
url: `/api/files/groups/${groupId}/neutralize`,
|
||||
method: 'patch',
|
||||
data: { neutralize },
|
||||
});
|
||||
}
|
||||
|
||||
/** Download all files in a group as ZIP */
|
||||
export async function downloadGroupZip(groupId: string): Promise<void> {
|
||||
const { default: api } = await import('../api');
|
||||
const response = await api.get(`/api/files/groups/${groupId}/download`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = window.URL.createObjectURL(response.data);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `group-${groupId}.zip`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Delete a group and optionally all its files */
|
||||
export async function deleteGroup(
|
||||
request: ApiRequestFunction,
|
||||
groupId: string,
|
||||
deleteItems: boolean = false
|
||||
): Promise<any> {
|
||||
return await request({
|
||||
url: `/api/files/groups/${groupId}`,
|
||||
method: 'delete',
|
||||
params: { recursive },
|
||||
params: { deleteItems },
|
||||
});
|
||||
}
|
||||
|
||||
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 },
|
||||
});
|
||||
/** @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() ?? [];
|
||||
}
|
||||
|
||||
// Note: The following operations require special handling (FormData, blob responses)
|
||||
|
|
@ -299,3 +271,121 @@ export async function moveFile(
|
|||
// - previewFile: Requires flexible responseType (json or blob)
|
||||
// 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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -46,6 +48,7 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -84,6 +87,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Neutralization API
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -49,6 +51,8 @@ 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> {
|
||||
|
|
@ -59,6 +63,8 @@ export interface PaginatedResponse<T> {
|
|||
totalItems: number;
|
||||
totalPages: number;
|
||||
};
|
||||
groupLayout?: import('./connectionApi').GroupLayout;
|
||||
appliedView?: { viewKey?: string; displayName?: string };
|
||||
}
|
||||
|
||||
export interface CreatePromptData {
|
||||
|
|
@ -110,7 +116,9 @@ 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
|
|
|
|||
400
src/api/redmineApi.ts
Normal file
400
src/api/redmineApi.ts
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Redmine API
|
||||
*
|
||||
* Frontend client for the Redmine feature backend.
|
||||
* URL pattern: /api/redmine/{instanceId}/...
|
||||
*/
|
||||
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
// Types -- mirror gateway/modules/features/redmine/datamodelRedmine.py
|
||||
// ============================================================================
|
||||
|
||||
export interface RedmineConfigDto {
|
||||
id?: string;
|
||||
featureInstanceId: string;
|
||||
mandateId?: string | null;
|
||||
baseUrl: string;
|
||||
projectId: string;
|
||||
hasApiKey: boolean;
|
||||
rootTrackerName: string;
|
||||
defaultPeriodValue?: Record<string, any> | null;
|
||||
schemaCacheTtlSeconds: number;
|
||||
schemaCachedAt?: number | null;
|
||||
isActive: boolean;
|
||||
lastConnectedAt?: number | null;
|
||||
lastSyncAt?: number | null;
|
||||
lastFullSyncAt?: number | null;
|
||||
lastSyncTicketCount?: number | null;
|
||||
lastSyncErrorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineConfigUpdateRequest {
|
||||
baseUrl?: string;
|
||||
projectId?: string;
|
||||
apiKey?: string;
|
||||
rootTrackerName?: string;
|
||||
defaultPeriodValue?: Record<string, any> | null;
|
||||
schemaCacheTtlSeconds?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface RedmineFieldChoice {
|
||||
id: number;
|
||||
name: string;
|
||||
isClosed?: boolean | null;
|
||||
}
|
||||
|
||||
export interface RedmineCustomFieldSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
fieldFormat: string;
|
||||
isRequired: boolean;
|
||||
possibleValues: string[];
|
||||
multiple: boolean;
|
||||
defaultValue?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineFieldSchema {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
trackers: RedmineFieldChoice[];
|
||||
statuses: RedmineFieldChoice[];
|
||||
priorities: RedmineFieldChoice[];
|
||||
users: RedmineFieldChoice[];
|
||||
categories: RedmineFieldChoice[];
|
||||
customFields: RedmineCustomFieldSchema[];
|
||||
rootTrackerName: string;
|
||||
rootTrackerId: number | null;
|
||||
}
|
||||
|
||||
export interface RedmineRelation {
|
||||
id: number;
|
||||
issueId: number;
|
||||
issueToId: number;
|
||||
relationType: string;
|
||||
delay?: number | null;
|
||||
}
|
||||
|
||||
export interface RedmineCustomFieldValue {
|
||||
id: number;
|
||||
name: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface RedmineTicket {
|
||||
id: number;
|
||||
subject: string;
|
||||
description: string;
|
||||
trackerId?: number | null;
|
||||
trackerName?: string | null;
|
||||
statusId?: number | null;
|
||||
statusName?: string | null;
|
||||
isClosed: boolean;
|
||||
priorityId?: number | null;
|
||||
priorityName?: string | null;
|
||||
assignedToId?: number | null;
|
||||
assignedToName?: string | null;
|
||||
authorId?: number | null;
|
||||
authorName?: string | null;
|
||||
parentId?: number | null;
|
||||
fixedVersionId?: number | null;
|
||||
fixedVersionName?: string | null;
|
||||
categoryId?: number | null;
|
||||
categoryName?: string | null;
|
||||
createdOn?: string | null;
|
||||
updatedOn?: string | null;
|
||||
customFields: RedmineCustomFieldValue[];
|
||||
relations: RedmineRelation[];
|
||||
}
|
||||
|
||||
export interface RedmineSyncResult {
|
||||
instanceId: string;
|
||||
full: boolean;
|
||||
ticketsUpserted: number;
|
||||
relationsUpserted: number;
|
||||
durationMs: number;
|
||||
lastSyncAt: number;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface RedmineSyncStatus {
|
||||
instanceId: string;
|
||||
lastSyncAt?: number | null;
|
||||
lastFullSyncAt?: number | null;
|
||||
lastSyncDurationMs?: number | null;
|
||||
lastSyncTicketCount?: number | null;
|
||||
lastSyncErrorAt?: number | null;
|
||||
lastSyncErrorMessage?: string | null;
|
||||
mirroredTicketCount: number;
|
||||
mirroredRelationCount: number;
|
||||
}
|
||||
|
||||
export interface RedmineConnectionTestResult {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
status?: number;
|
||||
user?: { id: number; name: string };
|
||||
project?: { id: number; name: string };
|
||||
}
|
||||
|
||||
export interface RedmineStats {
|
||||
instanceId: string;
|
||||
dateFrom?: string | null;
|
||||
dateTo?: string | null;
|
||||
bucket: string;
|
||||
trackerIds: number[];
|
||||
categoryIds: number[];
|
||||
statusFilter: string;
|
||||
kpis: {
|
||||
total: number;
|
||||
open: number;
|
||||
closed: number;
|
||||
closedInPeriod: number;
|
||||
createdInPeriod: number;
|
||||
orphans: number;
|
||||
};
|
||||
statusByTracker: Array<{
|
||||
trackerId?: number | null;
|
||||
trackerName: string;
|
||||
countsByStatus: Record<string, number>;
|
||||
total: number;
|
||||
}>;
|
||||
throughput: Array<{
|
||||
bucketKey: string;
|
||||
label: string;
|
||||
created: number;
|
||||
closed: number;
|
||||
cumTotal: number;
|
||||
cumOpen: number;
|
||||
}>;
|
||||
topAssignees: Array<{
|
||||
assignedToId?: number | null;
|
||||
name: string;
|
||||
open: number;
|
||||
}>;
|
||||
relationDistribution: Array<{ relationType: string; count: number }>;
|
||||
backlogAging: Array<{
|
||||
bucketKey: string;
|
||||
label: string;
|
||||
minDays: number;
|
||||
maxDays?: number | null;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
const _baseUrl = (instanceId: string): string => `/api/redmine/${instanceId}`;
|
||||
|
||||
// ============================================================================
|
||||
// Config
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineConfigDto> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'get' });
|
||||
}
|
||||
|
||||
export async function updateRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineConfigUpdateRequest,
|
||||
): Promise<RedmineConfigDto> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'put', data: body });
|
||||
}
|
||||
|
||||
export async function deleteRedmineConfigApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<{ deleted: boolean }> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config`, method: 'delete' });
|
||||
}
|
||||
|
||||
export async function testRedmineConnectionApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineConnectionTestResult> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/config/test`, method: 'post' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Schema
|
||||
// ============================================================================
|
||||
|
||||
export async function getRedmineSchemaApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
forceRefresh = false,
|
||||
): Promise<RedmineFieldSchema> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/schema`,
|
||||
method: 'get',
|
||||
params: forceRefresh ? { forceRefresh: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Sync
|
||||
// ============================================================================
|
||||
|
||||
export async function runRedmineSyncApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
force = false,
|
||||
): Promise<RedmineSyncResult> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/sync`,
|
||||
method: 'post',
|
||||
params: force ? { force: true } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRedmineSyncStatusApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
): Promise<RedmineSyncStatus> {
|
||||
return await request({ url: `${_baseUrl(instanceId)}/sync/status`, method: 'get' });
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tickets
|
||||
// ============================================================================
|
||||
|
||||
export interface ListTicketsParams {
|
||||
trackerIds?: number[];
|
||||
status?: 'open' | 'closed' | '*';
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
assignedToId?: number;
|
||||
}
|
||||
|
||||
export async function listRedmineTicketsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params: ListTicketsParams = {},
|
||||
): Promise<RedmineTicket[]> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params.status) queryParams.status = params.status;
|
||||
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
||||
if (params.assignedToId !== undefined) queryParams.assignedToId = params.assignedToId;
|
||||
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets`,
|
||||
method: 'get',
|
||||
params: queryParams,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export interface RedmineTicketUpdateBody {
|
||||
subject?: string;
|
||||
description?: string;
|
||||
trackerId?: number;
|
||||
statusId?: number;
|
||||
priorityId?: number;
|
||||
assignedToId?: number;
|
||||
parentIssueId?: number;
|
||||
fixedVersionId?: number;
|
||||
notes?: string;
|
||||
customFields?: Record<number, any>;
|
||||
}
|
||||
|
||||
export async function updateRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
body: RedmineTicketUpdateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'put',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RedmineTicketCreateBody {
|
||||
subject: string;
|
||||
trackerId: number;
|
||||
description?: string;
|
||||
statusId?: number;
|
||||
priorityId?: number;
|
||||
assignedToId?: number;
|
||||
parentIssueId?: number;
|
||||
fixedVersionId?: number;
|
||||
customFields?: Record<number, any>;
|
||||
}
|
||||
|
||||
export async function createRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: RedmineTicketCreateBody,
|
||||
): Promise<RedmineTicket> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets`,
|
||||
method: 'post',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteRedmineTicketApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
issueId: number,
|
||||
fallbackStatusId?: number,
|
||||
): Promise<{ deleted: boolean; archived: boolean; statusId: number | null }> {
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/tickets/${issueId}`,
|
||||
method: 'delete',
|
||||
params: fallbackStatusId !== undefined ? { fallbackStatusId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stats
|
||||
// ============================================================================
|
||||
|
||||
export interface RedmineStatsParams {
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
bucket?: 'day' | 'week' | 'month';
|
||||
trackerIds?: number[];
|
||||
categoryIds?: number[];
|
||||
statusFilter?: '*' | 'open' | 'closed';
|
||||
}
|
||||
|
||||
export async function getRedmineStatsApi(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params: RedmineStatsParams = {},
|
||||
): Promise<RedmineStats> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params.dateFrom) queryParams.dateFrom = params.dateFrom;
|
||||
if (params.dateTo) queryParams.dateTo = params.dateTo;
|
||||
if (params.bucket) queryParams.bucket = params.bucket;
|
||||
if (params.trackerIds && params.trackerIds.length > 0) queryParams.trackerIds = params.trackerIds;
|
||||
if (params.categoryIds && params.categoryIds.length > 0) queryParams.categoryIds = params.categoryIds;
|
||||
if (params.statusFilter && params.statusFilter !== '*') queryParams.statusFilter = params.statusFilter;
|
||||
return await request({
|
||||
url: `${_baseUrl(instanceId)}/stats`,
|
||||
method: 'get',
|
||||
params: queryParams,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Store API
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -42,6 +44,53 @@ 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 {
|
||||
|
|
@ -154,3 +203,40 @@ 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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
61
src/api/tableViewApi.ts
Normal file
61
src/api/tableViewApi.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
|
||||
export interface TableListViewRow {
|
||||
id: string;
|
||||
userId?: string;
|
||||
mandateId?: string | null;
|
||||
contextKey: string;
|
||||
viewKey: string;
|
||||
displayName: string;
|
||||
config: TableViewConfig;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface TableViewConfig {
|
||||
schemaVersion?: number;
|
||||
filters?: Record<string, unknown>;
|
||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
groupByLevels?: Array<{ field: string; nullLabel?: string }>;
|
||||
/** Section mode (`tableGroupLayoutMode="sections"`): stable keys (`sk`) of collapsed sections. */
|
||||
collapsedSectionKeys?: string[];
|
||||
/** Inline `groupLayout` bands: keys are `band.path.join('///')`. */
|
||||
collapsedGroupKeys?: string[];
|
||||
}
|
||||
|
||||
export async function listTableViews(contextKey: string): Promise<TableListViewRow[]> {
|
||||
const { data } = await api.get<TableListViewRow[]>('/api/table-views', {
|
||||
params: { contextKey },
|
||||
});
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function getTableView(contextKey: string, viewKey: string): Promise<TableListViewRow> {
|
||||
const { data } = await api.get<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewKey)}`, {
|
||||
params: { contextKey },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createTableView(payload: {
|
||||
contextKey: string;
|
||||
viewKey: string;
|
||||
displayName: string;
|
||||
config: TableViewConfig;
|
||||
}): Promise<TableListViewRow> {
|
||||
const { data } = await api.post<TableListViewRow>('/api/table-views', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTableView(
|
||||
viewId: string,
|
||||
updates: { displayName?: string; viewKey?: string; config?: TableViewConfig },
|
||||
): Promise<TableListViewRow> {
|
||||
const { data } = await api.put<TableListViewRow>(`/api/table-views/${encodeURIComponent(viewId)}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTableView(viewId: string): Promise<void> {
|
||||
await api.delete(`/api/table-views/${encodeURIComponent(viewId)}`);
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import api from '../api';
|
||||
import type { VoiceOption } from './voiceCatalogApi';
|
||||
|
||||
|
|
@ -9,6 +11,7 @@ export interface TeamsbotSession {
|
|||
id: string;
|
||||
instanceId: string;
|
||||
mandateId: string;
|
||||
moduleId?: string;
|
||||
meetingLink: string;
|
||||
botName: string;
|
||||
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||
|
|
@ -70,6 +73,7 @@ export interface TeamsbotConfig {
|
|||
triggerCooldownSeconds: number;
|
||||
contextWindowSegments: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
export interface TeamsbotSessionStats {
|
||||
|
|
@ -83,6 +87,7 @@ export interface TeamsbotSessionStats {
|
|||
export interface StartSessionRequest {
|
||||
meetingLink: string;
|
||||
botName?: string;
|
||||
moduleId?: string;
|
||||
connectionId?: string;
|
||||
joinMode?: TeamsbotJoinMode;
|
||||
sessionContext?: string;
|
||||
|
|
@ -101,6 +106,7 @@ export interface ConfigUpdateRequest {
|
|||
triggerCooldownSeconds?: number;
|
||||
contextWindowSegments?: number;
|
||||
debugMode?: boolean;
|
||||
avatarFileId?: string;
|
||||
}
|
||||
|
||||
// Voice option type re-exported from the central voice catalog API
|
||||
|
|
@ -169,11 +175,63 @@ export interface MfaChallengeEvent {
|
|||
|
||||
// SSE Event Types
|
||||
export interface TeamsbotSSEEvent {
|
||||
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
|
||||
type:
|
||||
| 'transcript'
|
||||
| 'botResponse'
|
||||
| 'analysis'
|
||||
| 'suggestedResponse'
|
||||
| 'statusChange'
|
||||
| 'error'
|
||||
| 'ping'
|
||||
| 'sessionState'
|
||||
| 'ttsDeliveryStatus'
|
||||
| 'mfaChallenge'
|
||||
| 'mfaResolved'
|
||||
| 'chatSendFailed'
|
||||
| 'directorPrompt'
|
||||
| 'agentRun'
|
||||
| 'botConnectionState';
|
||||
data: any;
|
||||
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
|
||||
// ============================================================================
|
||||
|
|
@ -289,6 +347,29 @@ 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.
|
||||
*/
|
||||
|
|
@ -386,6 +467,13 @@ 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)
|
||||
// =========================================================================
|
||||
|
|
@ -452,3 +540,127 @@ 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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Trustee API
|
||||
*
|
||||
|
|
@ -115,6 +117,7 @@ export interface AccountingConnectorInfo {
|
|||
secret: boolean;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
suggestions?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -852,16 +855,53 @@ export async function fetchChartOfAccounts(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits a background job that pushes positions to the accounting system and
|
||||
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
|
||||
* the same `{ total, success, skipped, errors, results }` payload that the
|
||||
* legacy synchronous endpoint used to return -- but does NOT block the user
|
||||
* while the (potentially long) external accounting calls run in the worker.
|
||||
*/
|
||||
export async function syncPositionsToAccounting(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
positionIds: string[]
|
||||
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
|
||||
return await request({
|
||||
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({
|
||||
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(
|
||||
|
|
@ -874,6 +914,91 @@ export async function fetchSyncStatus(
|
|||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
|
||||
// ============================================================================
|
||||
//
|
||||
// Generic read-only endpoints for the consolidated data tables view.
|
||||
// All entities are paginated, sortable, filterable via the Unified Filter API
|
||||
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
|
||||
|
||||
export interface TrusteeDataAccount { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataContact { id: string; [key: string]: any; }
|
||||
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
|
||||
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
|
||||
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
|
||||
|
||||
async function _fetchReadOnlyTable<T = any>(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
pathSegment: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<T> | T[]> {
|
||||
return await request({
|
||||
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
|
||||
method: 'get',
|
||||
params: _buildPaginationParams(params),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDataAccounts(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
|
||||
}
|
||||
|
||||
export async function fetchDataJournalEntries(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
|
||||
}
|
||||
|
||||
export async function fetchDataJournalLines(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
|
||||
}
|
||||
|
||||
export async function fetchDataContacts(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
|
||||
}
|
||||
|
||||
export async function fetchDataAccountBalances(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
|
||||
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
|
||||
}
|
||||
|
||||
export async function fetchAccountingConfigs(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
|
||||
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
|
||||
}
|
||||
|
||||
export async function fetchAccountingSyncs(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: PaginationParams
|
||||
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
|
||||
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
|
||||
}
|
||||
|
||||
export async function exportAccountingData(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -48,6 +50,7 @@ export interface PaginationParams {
|
|||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||
filters?: Record<string, any>;
|
||||
search?: string;
|
||||
viewKey?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
|
|
@ -152,6 +155,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Voice / Language Catalog API.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,827 +0,0 @@
|
|||
/**
|
||||
* Workflow API (GraphicalEditor)
|
||||
* Node types and graph execution for n8n-style flows.
|
||||
*/
|
||||
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
const LOG = '[Workflow]';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface NodeTypeParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
frontendType?: string;
|
||||
frontendOptions?: Record<string, unknown>;
|
||||
options?: unknown[];
|
||||
validation?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PortField {
|
||||
name: string;
|
||||
type: string;
|
||||
description: Record<string, string>;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface PortSchema {
|
||||
name: string;
|
||||
fields: PortField[];
|
||||
}
|
||||
|
||||
export interface InputPortDef {
|
||||
accepts: string[];
|
||||
}
|
||||
|
||||
export interface OutputPortDef {
|
||||
schema: string;
|
||||
dynamic?: boolean;
|
||||
deriveFrom?: string;
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
id: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: NodeTypeParameter[];
|
||||
inputs: number;
|
||||
outputs: number;
|
||||
outputLabels?: string[];
|
||||
executor: string;
|
||||
inputPorts?: Record<number, InputPortDef>;
|
||||
outputPorts?: Record<number, OutputPortDef>;
|
||||
meta?: {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
/** True if this node performs an LLM / AI call (credits). */
|
||||
usesAi?: boolean;
|
||||
method?: string;
|
||||
action?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeTypeCategory {
|
||||
id: string;
|
||||
label: Record<string, string> | string;
|
||||
}
|
||||
|
||||
export interface SystemVariable {
|
||||
type: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NodeTypesResponse {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
}
|
||||
|
||||
export interface Automation2GraphNode {
|
||||
id: string;
|
||||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||
outputPorts?: Array<{ name: string; schema: string }>;
|
||||
}
|
||||
|
||||
export interface Automation2Connection {
|
||||
source: string;
|
||||
target: string;
|
||||
sourceOutput?: number;
|
||||
targetInput?: number;
|
||||
}
|
||||
|
||||
export interface Automation2Graph {
|
||||
nodes: Automation2GraphNode[];
|
||||
connections: Automation2Connection[];
|
||||
}
|
||||
|
||||
export interface ExecuteGraphResponse {
|
||||
success: boolean;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
stopped?: boolean;
|
||||
failedNode?: string;
|
||||
paused?: boolean;
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
|
||||
export interface WorkflowEntryPoint {
|
||||
id: string;
|
||||
kind: string;
|
||||
category: 'on_demand' | 'always_on';
|
||||
enabled: boolean;
|
||||
title: Record<string, string> | string;
|
||||
description?: Record<string, string>;
|
||||
config: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Automation2Workflow {
|
||||
id: string;
|
||||
label: string;
|
||||
graph: Automation2Graph;
|
||||
active?: boolean;
|
||||
/** Entry points (Starts) — how this workflow may be invoked */
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
/** Enriched: run count */
|
||||
runCount?: number;
|
||||
/** Enriched: has active (running/paused) run */
|
||||
isRunning?: boolean;
|
||||
/** Enriched: status of active run */
|
||||
runStatus?: string;
|
||||
/** Enriched: nodeId where workflow is stuck (paused) */
|
||||
stuckAtNodeId?: string;
|
||||
/** Enriched: human-readable label for stuck node */
|
||||
stuckAtNodeLabel?: string;
|
||||
/** Enriched: created timestamp (seconds) */
|
||||
createdAt?: number;
|
||||
/** Enriched: last run started timestamp (seconds) */
|
||||
lastStartedAt?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AUTO-PREFIX TYPES (Greenfield)
|
||||
// ============================================================================
|
||||
|
||||
export type AutoWorkflowStatus = 'draft' | 'published' | 'archived';
|
||||
export type AutoRunStatus = 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AutoStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
export type AutoTaskStatus = 'pending' | 'completed' | 'cancelled' | 'expired';
|
||||
export type AutoTemplateScope = 'user' | 'instance' | 'mandate' | 'system';
|
||||
|
||||
export interface AutoVersion {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
versionNumber: number;
|
||||
status: AutoWorkflowStatus;
|
||||
graph: Automation2Graph;
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
publishedAt?: number;
|
||||
publishedBy?: string;
|
||||
}
|
||||
|
||||
export interface AutoRun {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
versionId?: string;
|
||||
status: AutoRunStatus;
|
||||
trigger?: Record<string, unknown>;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
currentNodeId?: string;
|
||||
resumeContext?: Record<string, unknown>;
|
||||
error?: string;
|
||||
costTokens?: number;
|
||||
costCredits?: number;
|
||||
}
|
||||
|
||||
export interface AutoWorkflow {
|
||||
id: string;
|
||||
mandateId: string;
|
||||
featureInstanceId: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
isTemplate: boolean;
|
||||
templateSourceId?: string;
|
||||
templateScope?: AutoTemplateScope;
|
||||
sharedReadOnly?: boolean;
|
||||
currentVersionId?: string;
|
||||
active: boolean;
|
||||
eventId?: string;
|
||||
notifyOnFailure?: boolean;
|
||||
graph: Automation2Graph;
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
sysCreatedBy?: string;
|
||||
sysCreatedAt?: number;
|
||||
sysModifiedBy?: string;
|
||||
sysModifiedAt?: number;
|
||||
}
|
||||
|
||||
export interface AutoTask {
|
||||
id: string;
|
||||
runId: string;
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
config: Record<string, unknown>;
|
||||
assigneeId?: string;
|
||||
status: AutoTaskStatus;
|
||||
result?: Record<string, unknown>;
|
||||
expiresAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
}
|
||||
|
||||
export interface AutoStepLog {
|
||||
id: string;
|
||||
runId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
status: AutoStepStatus;
|
||||
inputSnapshot?: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
error?: string;
|
||||
startedAt?: number;
|
||||
completedAt?: number;
|
||||
durationMs?: number;
|
||||
tokensUsed?: number;
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Fetch node types for the flow builder (backend-driven).
|
||||
* GET /api/workflows/{instanceId}/node-types?language=de
|
||||
*/
|
||||
export async function fetchNodeTypes(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
language = 'de'
|
||||
): Promise<NodeTypesResponse> {
|
||||
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/node-types`,
|
||||
method: 'get',
|
||||
params: { language },
|
||||
});
|
||||
const nodeTypes = data?.nodeTypes ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||
return { nodeTypes, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automation2 graph.
|
||||
* POST /api/workflows/{instanceId}/execute
|
||||
*/
|
||||
export interface ExecuteGraphOptions {
|
||||
/** Use a configured start on the saved workflow */
|
||||
entryPointId?: string;
|
||||
/** Full run envelope (overrides entry point mapping) */
|
||||
runEnvelope?: Record<string, unknown>;
|
||||
/** Merged into envelope.payload */
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function executeGraph(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
graph: Automation2Graph,
|
||||
workflowId?: string,
|
||||
options?: ExecuteGraphOptions
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
console.log(
|
||||
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
|
||||
{ nodes: graph.nodes, connections: graph.connections, options }
|
||||
);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const data: Record<string, unknown> = { graph, workflowId };
|
||||
if (options?.entryPointId) data.entryPointId = options.entryPointId;
|
||||
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
|
||||
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
|
||||
const result = await request({
|
||||
url: `/api/workflows/${instanceId}/execute`,
|
||||
method: 'post',
|
||||
data,
|
||||
});
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.log(
|
||||
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
|
||||
result
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.error(
|
||||
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
|
||||
err
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Workflows CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export async function fetchWorkflows(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: { active?: boolean; pagination?: any }
|
||||
): Promise<Automation2Workflow[] | { items: Automation2Workflow[]; pagination: any }> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (params?.active !== undefined) queryParams.active = params.active;
|
||||
if (params?.pagination) queryParams.pagination = JSON.stringify(params.pagination);
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/workflows`,
|
||||
method: 'get',
|
||||
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
|
||||
});
|
||||
if (data?.items && data?.pagination) return data;
|
||||
return data?.workflows ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/workflows`,
|
||||
method: 'post',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string,
|
||||
body: {
|
||||
label?: string;
|
||||
graph?: Automation2Graph;
|
||||
invocations?: WorkflowEntryPoint[];
|
||||
active?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
}
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'put',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
|
||||
export async function deleteSystemWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
workflowId: string,
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/system/workflow-runs/workflows/${workflowId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export interface Automation2Run {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
status: string;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
currentNodeId?: string;
|
||||
}
|
||||
|
||||
export async function fetchWorkflowRuns(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Run[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}/runs`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.runs ?? [];
|
||||
}
|
||||
|
||||
export interface CompletedRun extends Automation2Run {
|
||||
workflowLabel?: string;
|
||||
sysModifiedAt?: number;
|
||||
sysCreatedAt?: number;
|
||||
}
|
||||
|
||||
export async function fetchCompletedRuns(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
limit = 20
|
||||
): Promise<CompletedRun[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/runs/completed`,
|
||||
method: 'get',
|
||||
params: { limit },
|
||||
});
|
||||
return data?.runs ?? [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface Automation2Task {
|
||||
id: string;
|
||||
runId: string;
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
result?: Record<string, unknown>;
|
||||
/** Workflow label (enriched by API) */
|
||||
workflowLabel?: string;
|
||||
/** Unix timestamp ms (from sysCreatedAt) */
|
||||
createdAt?: number;
|
||||
/** Optional due date - configurable in future */
|
||||
dueAt?: number;
|
||||
}
|
||||
|
||||
export async function fetchTasks(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: { workflowId?: string; status?: string }
|
||||
): Promise<Automation2Task[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/tasks`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function completeTask(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
taskId: string,
|
||||
result: Record<string, unknown>
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/tasks/${taskId}/complete`,
|
||||
method: 'post',
|
||||
data: { result },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Versions (AutoVersion Lifecycle)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export async function fetchVersions(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<AutoVersion[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.versions ?? [];
|
||||
}
|
||||
|
||||
export async function createDraftVersion(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<AutoVersion> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions/draft`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
export async function publishVersion(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
versionId: string
|
||||
): Promise<AutoVersion> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/versions/${versionId}/publish`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
export async function unpublishVersion(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
versionId: string
|
||||
): Promise<AutoVersion> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/versions/${versionId}/unpublish`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveVersion(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
versionId: string
|
||||
): Promise<AutoVersion> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/versions/${versionId}/archive`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Templates
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface AutoWorkflowTemplate extends Automation2Workflow {
|
||||
isTemplate: boolean;
|
||||
templateScope?: AutoTemplateScope;
|
||||
templateSourceId?: string;
|
||||
sharedReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export async function fetchTemplates(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
scope?: AutoTemplateScope,
|
||||
pagination?: any
|
||||
): Promise<AutoWorkflowTemplate[] | { items: AutoWorkflowTemplate[]; pagination: any }> {
|
||||
const queryParams: Record<string, any> = {};
|
||||
if (scope) queryParams.scope = scope;
|
||||
if (pagination) queryParams.pagination = JSON.stringify(pagination);
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/templates`,
|
||||
method: 'get',
|
||||
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
|
||||
});
|
||||
if (data?.items && data?.pagination) return data;
|
||||
return data?.templates ?? [];
|
||||
}
|
||||
|
||||
export async function createTemplateFromWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string,
|
||||
scope: AutoTemplateScope = 'user'
|
||||
): Promise<AutoWorkflowTemplate> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/templates/from-workflow`,
|
||||
method: 'post',
|
||||
data: { workflowId, scope },
|
||||
});
|
||||
}
|
||||
|
||||
export async function copyTemplate(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
templateId: string
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/templates/${templateId}/copy`,
|
||||
method: 'post',
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareTemplate(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
templateId: string,
|
||||
scope: AutoTemplateScope
|
||||
): Promise<AutoWorkflowTemplate> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/templates/${templateId}/share`,
|
||||
method: 'post',
|
||||
data: { scope },
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Connections and Browse (for Email/SharePoint node config)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface UserConnection {
|
||||
id: string;
|
||||
authority: string;
|
||||
externalUsername?: string;
|
||||
externalEmail?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export async function fetchConnections(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
): Promise<UserConnection[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/connections`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.connections ?? [];
|
||||
}
|
||||
|
||||
export interface ConnectionService {
|
||||
service: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export async function fetchConnectionServices(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
connectionId: string
|
||||
): Promise<ConnectionService[]> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/connections/${connectionId}/services`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.services ?? [];
|
||||
}
|
||||
|
||||
export interface BrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
isFolder: boolean;
|
||||
size?: number;
|
||||
mimeType?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchBrowse(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
connectionId: string,
|
||||
service: string,
|
||||
path = '/'
|
||||
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/connections/${connectionId}/browse`,
|
||||
method: 'get',
|
||||
params: { service, path },
|
||||
});
|
||||
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
|
||||
}
|
||||
|
||||
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
|
||||
export async function fetchClickupTask(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
taskId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
|
||||
export async function fetchClickupList(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
|
||||
export async function fetchClickupTeam(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
teamId: string
|
||||
): Promise<Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/teams/${teamId}`,
|
||||
method: 'get',
|
||||
});
|
||||
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
|
||||
export async function fetchClickupListFields(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
|
||||
method: 'get',
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
|
||||
export interface ClickupListTaskItem {
|
||||
id?: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export async function fetchClickupListTasks(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string,
|
||||
options?: { page?: number; includeClosed?: boolean }
|
||||
): Promise<
|
||||
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
|
||||
> {
|
||||
const data = await request({
|
||||
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
|
||||
method: 'get',
|
||||
params: {
|
||||
page: options?.page ?? 0,
|
||||
include_closed: options?.includeClosed ?? false,
|
||||
},
|
||||
});
|
||||
return (data && typeof data === 'object' ? data : {}) as {
|
||||
tasks?: ClickupListTaskItem[];
|
||||
last_page?: boolean;
|
||||
} & Record<string, unknown>;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Monitoring / Metrics
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface WorkflowMetrics {
|
||||
workflowCount: number;
|
||||
activeWorkflows: number;
|
||||
totalRuns: number;
|
||||
runsByStatus: Record<string, number>;
|
||||
totalTasks: number;
|
||||
tasksByStatus: Record<string, number>;
|
||||
totalTokens: number;
|
||||
totalCredits: number;
|
||||
}
|
||||
|
||||
export async function fetchMetrics(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
): Promise<WorkflowMetrics> {
|
||||
return await request({
|
||||
url: `/api/workflows/${instanceId}/metrics`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
|
||||
export async function loadClickupListTasksForDropdown(
|
||||
request: ApiRequestFunction,
|
||||
connectionId: string,
|
||||
listId: string
|
||||
): Promise<Array<{ id: string; name: string }>> {
|
||||
const acc: Array<{ id: string; name: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
const maxPages = 12;
|
||||
const pageSizeHint = 100;
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const data = await fetchClickupListTasks(request, connectionId, listId, {
|
||||
page,
|
||||
includeClosed: false,
|
||||
});
|
||||
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
|
||||
const err = (data as { error?: unknown }).error;
|
||||
const body = (data as { body?: string }).body;
|
||||
throw new Error(
|
||||
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
|
||||
);
|
||||
}
|
||||
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
for (const t of tasks) {
|
||||
const id = t?.id != null ? String(t.id) : '';
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
acc.push({ id, name: String(t.name ?? id) });
|
||||
}
|
||||
const rawLast = (data as Record<string, unknown>).last_page;
|
||||
const last =
|
||||
rawLast === true ||
|
||||
rawLast === 'true' ||
|
||||
tasks.length === 0 ||
|
||||
tasks.length < pageSizeHint;
|
||||
if (last) break;
|
||||
}
|
||||
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
return acc;
|
||||
}
|
||||
1197
src/api/workflowAutomationApi.ts
Normal file
1197
src/api/workflowAutomationApi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessLevelSelect
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRulesEditor
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRulesTable
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AccessRules Components
|
||||
*
|
||||
|
|
|
|||
|
|
@ -0,0 +1,482 @@
|
|||
/* AddConnectionWizard styles */
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem 1.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.stepDot {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
background: var(--bg-secondary, #f0f0f0);
|
||||
color: var(--text-secondary, #666);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.stepDotActive {
|
||||
background: var(--primary-color, #f25843);
|
||||
border-color: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stepDotDone {
|
||||
background: var(--success-color, #22c55e);
|
||||
border-color: var(--success-color, #22c55e);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stepDotHidden {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stepContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepBody {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepHint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Connector grid (Step 0) */
|
||||
.connectorGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.connectorCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 1.25rem 1rem;
|
||||
background: var(--surface-color);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
|
||||
}
|
||||
|
||||
.connectorCard:hover {
|
||||
border-color: var(--primary-color, #f25843);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.connectorIcon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.connectorLabel {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Consent step (Step 1) */
|
||||
.consentIcon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.consentButtons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.consentButtonYes {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.consentButtonYes:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.consentButtonNo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
border: 2px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.consentButtonNo:hover {
|
||||
border-color: var(--text-secondary, #888);
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
/* Preferences step (Step 2) */
|
||||
.prefGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.prefGroup:last-of-type {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.prefLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefLabelRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prefIcon {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.prefCheck {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.prefSelect {
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.prefNumber {
|
||||
width: 80px;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.prefHint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Summary step (Step 3) */
|
||||
.summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summaryRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.625rem 1rem;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #eee);
|
||||
}
|
||||
|
||||
.summaryRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summaryKey {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.summaryVal {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Back button (step 1 consent screen) */
|
||||
.stepNavLeft {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navBack {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.25rem 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.navBack:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Cost estimate hint */
|
||||
.costHint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--info-bg, #eff6ff);
|
||||
border: 1px solid var(--info-border, #bfdbfe);
|
||||
border-radius: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.costHintIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--info-color, #3b82f6);
|
||||
}
|
||||
|
||||
.costHint > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.costHintTitle {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.125rem;
|
||||
}
|
||||
|
||||
.costTable {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.costLabel {
|
||||
color: var(--text-secondary, #555);
|
||||
padding-right: 1rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.costVal {
|
||||
font-weight: 600;
|
||||
color: var(--info-color, #1d4ed8);
|
||||
}
|
||||
|
||||
.costRowNeut .costLabel,
|
||||
.costRowNeut .costVal {
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.costRowNeut .costVal {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.costHintWarn {
|
||||
font-size: 0.75rem;
|
||||
color: #b45309;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.costHintNote {
|
||||
color: var(--text-secondary, #555);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costHint {
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border-color: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costVal {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global(.dark-theme) .costRowNeut .costVal,
|
||||
:global(.dark-theme) .costHintWarn {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.stepNav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.navBack {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--surface-color);
|
||||
color: var(--text-secondary, #666);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navBack:hover {
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
}
|
||||
|
||||
.navNext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navNext:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.navConnect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1.5rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.navConnect:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.navConnect:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.patInput {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: monospace;
|
||||
margin: 12px 0 16px;
|
||||
}
|
||||
|
||||
.patInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary, #2563eb);
|
||||
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:global(.dark-theme) .connectorCard {
|
||||
background: var(--surface-color);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .prefSelect,
|
||||
:global(.dark-theme) .prefNumber {
|
||||
background: var(--surface-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .summary {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .summaryRow {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
295
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal file
295
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* AddConnectionWizard
|
||||
*
|
||||
* Streamlined multi-step modal for adding a new connector.
|
||||
* Steps are connector-type-aware:
|
||||
* Base: Connector → Consent → Connect
|
||||
* Microsoft: Connector → Consent → Admin Consent (optional) → Connect
|
||||
* Infomaniak: Connector → Consent → PAT Input → (done)
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Modal } from '../UiComponents/Modal/Modal';
|
||||
import { FaGoogle, FaMicrosoft, FaTasks, FaCloud, FaCheck, FaArrowRight, FaShieldAlt } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import styles from './AddConnectionWizard.module.css';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectorType = 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||
|
||||
type StepId = 'connector' | 'consent' | 'msftAdminConsent' | 'infomaniakPat' | 'connect';
|
||||
|
||||
interface WizardState {
|
||||
currentStep: StepId;
|
||||
connector: ConnectorType | null;
|
||||
knowledgeEnabled: boolean;
|
||||
infomaniakToken: string;
|
||||
adminConsentDone: boolean;
|
||||
}
|
||||
|
||||
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
|
||||
google: 'Google',
|
||||
msft: 'Microsoft 365',
|
||||
clickup: 'ClickUp',
|
||||
infomaniak: 'Infomaniak',
|
||||
};
|
||||
|
||||
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
|
||||
google: <FaGoogle style={{ color: '#4285f4' }} />,
|
||||
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
|
||||
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
|
||||
infomaniak: <FaCloud style={{ color: '#0098db' }} />,
|
||||
};
|
||||
|
||||
function _getSteps(connector: ConnectorType | null): StepId[] {
|
||||
if (connector === 'msft') return ['connector', 'consent', 'msftAdminConsent', 'connect'];
|
||||
if (connector === 'infomaniak') return ['connector', 'consent', 'infomaniakPat'];
|
||||
return ['connector', 'consent', 'connect'];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AddConnectionWizardProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConnect: (type: ConnectorType, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onInfomaniakConnect?: (token: string, knowledgeEnabled: boolean) => Promise<void>;
|
||||
onMsftAdminConsent?: () => void;
|
||||
isConnecting?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
onConnect,
|
||||
onInfomaniakConnect,
|
||||
onMsftAdminConsent,
|
||||
isConnecting = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [state, setState] = useState<WizardState>({
|
||||
currentStep: 'connector',
|
||||
connector: null,
|
||||
knowledgeEnabled: false,
|
||||
infomaniakToken: '',
|
||||
adminConsentDone: false,
|
||||
});
|
||||
|
||||
const reset = () =>
|
||||
setState({ currentStep: 'connector', connector: null, knowledgeEnabled: false, infomaniakToken: '', adminConsentDone: false });
|
||||
|
||||
const handleClose = () => { reset(); onClose(); };
|
||||
|
||||
const steps = _getSteps(state.connector);
|
||||
const stepIndex = steps.indexOf(state.currentStep);
|
||||
|
||||
const goNext = () => {
|
||||
const nextIdx = stepIndex + 1;
|
||||
if (nextIdx < steps.length) {
|
||||
setState(s => ({ ...s, currentStep: steps[nextIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
const prevIdx = stepIndex - 1;
|
||||
if (prevIdx >= 0) {
|
||||
setState(s => ({ ...s, currentStep: steps[prevIdx] }));
|
||||
}
|
||||
};
|
||||
|
||||
const selectConnector = (c: ConnectorType) => {
|
||||
setState(s => ({ ...s, connector: c, currentStep: 'consent' }));
|
||||
};
|
||||
|
||||
const setConsent = (enabled: boolean) => {
|
||||
setState(s => ({ ...s, knowledgeEnabled: enabled }));
|
||||
goNext();
|
||||
};
|
||||
|
||||
const handleFinalConnect = async () => {
|
||||
if (!state.connector) return;
|
||||
if (state.connector === 'infomaniak' && onInfomaniakConnect) {
|
||||
await onInfomaniakConnect(state.infomaniakToken, state.knowledgeEnabled);
|
||||
} else {
|
||||
await onConnect(state.connector, state.knowledgeEnabled);
|
||||
}
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleClose} title={t('Verbindung hinzufügen')} size="md" closeOnEscape>
|
||||
{/* Stepper */}
|
||||
<div className={styles.stepper}>
|
||||
{steps.map((s, i) => (
|
||||
<div
|
||||
key={s}
|
||||
className={[
|
||||
styles.stepDot,
|
||||
stepIndex === i ? styles.stepDotActive : '',
|
||||
stepIndex > i ? styles.stepDotDone : '',
|
||||
].join(' ')}
|
||||
>
|
||||
{stepIndex > i ? <FaCheck size={10} /> : i + 1}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{/* ---- Step: Connector ---- */}
|
||||
{state.currentStep === 'connector' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Anbieter wählen')}</h3>
|
||||
<p className={styles.stepHint}>{t('Welchen Dienst möchtest du verbinden?')}</p>
|
||||
<div className={styles.connectorGrid}>
|
||||
{(['google', 'msft', 'clickup', 'infomaniak'] as ConnectorType[]).map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
className={styles.connectorCard}
|
||||
onClick={() => selectConnector(type)}
|
||||
>
|
||||
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
|
||||
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Consent ---- */}
|
||||
{state.currentStep === 'consent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Wissensdatenbank')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Möchtest du Inhalte aus dieser Verbindung in deine persönliche Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen aus {provider} zurückgreifen kann?', { provider: state.connector ? CONNECTOR_LABELS[state.connector] : t('diesem Dienst') })}
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Du kannst dies später jederzeit in der UDB pro Datenquelle steuern.')}
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button type="button" className={styles.consentButtonYes} onClick={() => setConsent(true)}>
|
||||
<FaCheck /> {t('Ja, aktivieren')}
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={() => setConsent(false)}>
|
||||
{t('Nein, überspringen')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: MSFT Admin Consent ---- */}
|
||||
{state.currentStep === 'msftAdminConsent' && (
|
||||
<div className={styles.stepContent}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 16 }}>
|
||||
<FaShieldAlt size={32} style={{ color: '#00a4ef' }} />
|
||||
</div>
|
||||
<h3 className={styles.stepTitle}>{t('Organisations-Zustimmung (optional)')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Falls du Mandant-Administrator bist, kannst du jetzt für deine ganze Organisation zustimmen. So müssen andere Benutzer nicht einzeln bestätigen.')}
|
||||
</p>
|
||||
<p className={styles.stepHint}>
|
||||
{t('Wenn du kein Admin bist oder dies später tun möchtest, überspringe diesen Schritt.')}
|
||||
</p>
|
||||
<div className={styles.consentButtons}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.consentButtonYes}
|
||||
onClick={() => { onMsftAdminConsent?.(); setState(s => ({ ...s, adminConsentDone: true })); goNext(); }}
|
||||
>
|
||||
<FaShieldAlt /> {t('Admin-Zustimmung erteilen')}
|
||||
</button>
|
||||
<button type="button" className={styles.consentButtonNo} onClick={goNext}>
|
||||
{t('Überspringen')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.stepNavLeft}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Infomaniak PAT ---- */}
|
||||
{state.currentStep === 'infomaniakPat' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Infomaniak Personal Access Token')}</h3>
|
||||
<p className={styles.stepBody}>
|
||||
{t('Erstelle einen Personal Access Token in deinem Infomaniak-Konto und füge ihn hier ein.')}
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="pat_..."
|
||||
value={state.infomaniakToken}
|
||||
onChange={e => setState(s => ({ ...s, infomaniakToken: e.target.value }))}
|
||||
className={styles.patInput}
|
||||
autoFocus
|
||||
/>
|
||||
<div className={styles.stepNav}>
|
||||
<button type="button" className={styles.navBack} onClick={goBack}>{t('Zurück')}</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.navConnect}
|
||||
onClick={handleFinalConnect}
|
||||
disabled={isConnecting || !state.infomaniakToken.trim()}
|
||||
>
|
||||
{isConnecting ? t('Verbinden…') : t('Verbinden')}
|
||||
{!isConnecting && <FaArrowRight size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ---- Step: Connect ---- */}
|
||||
{state.currentStep === 'connect' && (
|
||||
<div className={styles.stepContent}>
|
||||
<h3 className={styles.stepTitle}>{t('Verbindung herstellen')}</h3>
|
||||
<div className={styles.summary}>
|
||||
<div className={styles.summaryRow}>
|
||||
<span className={styles.summaryKey}>{t('Anbieter')}</span>
|
||||
<span className={styles.summaryVal}>
|
||||
{state.connector && CONNECTOR_ICONS[state.connector]}
|
||||
{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;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ChatInput -- Shared chat input component.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ChatMessageList -- Shared chat message display component.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { ChatMessageList } from './ChatMessageList';
|
||||
export type { ChatMessage } from './ChatMessageList';
|
||||
export { ChatInput } from './ChatInput';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { ContentPreview } from './ContentPreview';
|
||||
export type { ContentPreviewProps } from './ContentPreview';
|
||||
export { UrlContentPreview } from './UrlContentPreview';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ApplicationRendererProps {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface HtmlRendererProps {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
interface ImageRendererProps {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useState } from 'react';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// @ts-ignore
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import styles from '../ContentPreview.module.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { JsonRenderer } from './JsonRenderer';
|
||||
export { ImageRenderer } from './ImageRenderer';
|
||||
export { TextRenderer } from './TextRenderer';
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* Automation2 Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
||||
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { NodeType, PortSchema, SystemVariable } from '../../../api/workflowApi';
|
||||
|
||||
export interface Automation2DataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
systemVariables: Record<string, SystemVariable>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
}
|
||||
|
||||
const Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
|
||||
|
||||
export function useAutomation2DataFlow(): Automation2DataFlowContextValue | null {
|
||||
return useContext(Automation2DataFlowContext);
|
||||
}
|
||||
|
||||
interface Automation2DataFlowProviderProps {
|
||||
node: CanvasNode | null;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderProps> = ({
|
||||
node,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): Automation2DataFlowContextValue | null => {
|
||||
if (!node) return null;
|
||||
return {
|
||||
currentNodeId: node.id,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog,
|
||||
systemVariables,
|
||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
};
|
||||
}, [node?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
|
||||
|
||||
return (
|
||||
<Automation2DataFlowContext.Provider value={value}>
|
||||
{children}
|
||||
</Automation2DataFlowContext.Provider>
|
||||
);
|
||||
};
|
||||
144
src/components/FlowEditor/context/WorkflowDataFlowContext.tsx
Normal file
144
src/components/FlowEditor/context/WorkflowDataFlowContext.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Workflow Flow Editor - Data flow context for Data Picker and DynamicValueField.
|
||||
* Extended with portTypeCatalog and systemVariables for the Typed Port System.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||
import type { ApiRequestFunction, ConditionOperatorDef, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowAutomationApi';
|
||||
|
||||
export interface WorkflowDataFlowContextValue {
|
||||
currentNodeId: string;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog: Record<string, PortSchema>;
|
||||
systemVariables: Record<string, SystemVariable>;
|
||||
/** Canonical form field types from the API — maps UI type id to portType primitive. */
|
||||
formFieldTypes: FormFieldType[];
|
||||
/** Backend-driven condition operators per valueKind (flow.ifElse). */
|
||||
conditionOperatorCatalog: Record<string, ConditionOperatorDef[]>;
|
||||
getNodeLabel: (node: { id: string; title?: string; label?: string; type?: string }) => string;
|
||||
getAvailableSourceIds: () => string[];
|
||||
/** Present when rendered inside the flow editor (ConnectionPicker / tools). */
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
/** Build FormPayload-like schema from ``parameters[parameterKey]`` (fieldBuilder JSON). */
|
||||
parseGraphDefinedSchema: (parameterKey: string) => PortSchema | null;
|
||||
}
|
||||
|
||||
const WorkflowDataFlowContext = createContext<WorkflowDataFlowContextValue | null>(null);
|
||||
|
||||
export function useWorkflowDataFlow(): WorkflowDataFlowContextValue | null {
|
||||
return useContext(WorkflowDataFlowContext);
|
||||
}
|
||||
|
||||
interface WorkflowDataFlowProviderProps {
|
||||
node: CanvasNode | null;
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeOutputsPreview: Record<string, unknown>;
|
||||
nodeTypes: NodeType[];
|
||||
language: string;
|
||||
portTypeCatalog?: Record<string, PortSchema>;
|
||||
systemVariables?: Record<string, SystemVariable>;
|
||||
formFieldTypes?: FormFieldType[];
|
||||
conditionOperatorCatalog?: Record<string, ConditionOperatorDef[]>;
|
||||
instanceId?: string;
|
||||
request?: ApiRequestFunction;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const WorkflowDataFlowProvider: React.FC<WorkflowDataFlowProviderProps> = ({
|
||||
node,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog = {},
|
||||
systemVariables = {},
|
||||
formFieldTypes = [],
|
||||
conditionOperatorCatalog = {},
|
||||
instanceId,
|
||||
request,
|
||||
children,
|
||||
}) => {
|
||||
const value = useMemo((): WorkflowDataFlowContextValue | null => {
|
||||
if (!node) return null;
|
||||
const formTypeToPort: Record<string, string> = Object.fromEntries(
|
||||
formFieldTypes.map((f) => [f.id, f.portType])
|
||||
);
|
||||
const resolvePortType = (rawType: string): string => formTypeToPort[rawType] ?? rawType;
|
||||
|
||||
const parseGraphDefinedSchema = (parameterKey: string): PortSchema | null => {
|
||||
const raw = node.parameters?.[parameterKey];
|
||||
if (!Array.isArray(raw)) return null;
|
||||
const fields: PortField[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== 'object' || item === null) continue;
|
||||
const rec = item as Record<string, unknown>;
|
||||
if (typeof rec.name !== 'string') continue;
|
||||
const lab = rec.label;
|
||||
const desc =
|
||||
typeof lab === 'string' ? lab : typeof lab === 'object' && lab !== null ? String((lab as Record<string, string>).de ?? '') : '';
|
||||
const rawType = typeof rec.type === 'string' ? rec.type : 'str';
|
||||
if (rawType === 'group' && Array.isArray(rec.fields)) {
|
||||
for (const sub of rec.fields as Record<string, unknown>[]) {
|
||||
if (!sub || typeof sub.name !== 'string') continue;
|
||||
const sl = sub.label;
|
||||
const sdesc =
|
||||
typeof sl === 'string'
|
||||
? sl
|
||||
: typeof sl === 'object' && sl !== null
|
||||
? String((sl as Record<string, string>).de ?? '')
|
||||
: '';
|
||||
fields.push({
|
||||
name: `${rec.name}.${sub.name}`,
|
||||
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
|
||||
description: (sdesc && sdesc.trim()) || `${rec.name}.${sub.name}`,
|
||||
required: Boolean(sub.required),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
fields.push({
|
||||
name: rec.name,
|
||||
type: resolvePortType(rawType),
|
||||
description: (desc && desc.trim()) || rec.name,
|
||||
required: Boolean(rec.required),
|
||||
});
|
||||
}
|
||||
return fields.length ? { name: 'FormPayload_dynamic', fields } : null;
|
||||
};
|
||||
return {
|
||||
currentNodeId: node.id,
|
||||
nodes,
|
||||
connections,
|
||||
nodeOutputsPreview,
|
||||
nodeTypes,
|
||||
language,
|
||||
portTypeCatalog,
|
||||
systemVariables,
|
||||
formFieldTypes,
|
||||
conditionOperatorCatalog,
|
||||
getNodeLabel: (n: { id: string; title?: string; label?: string; type?: string }) =>
|
||||
n.title ?? n.label ?? n.type ?? n.id,
|
||||
getAvailableSourceIds: () => getAvailableSources(node.id, nodes, connections),
|
||||
instanceId,
|
||||
request,
|
||||
parseGraphDefinedSchema,
|
||||
};
|
||||
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, conditionOperatorCatalog, instanceId, request]);
|
||||
|
||||
return (
|
||||
<WorkflowDataFlowContext.Provider value={value}>
|
||||
{children}
|
||||
</WorkflowDataFlowContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,26 +1,83 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
|
||||
* CanvasHeader - Workflow controls, version selector, and execute result.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { 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 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 { 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: Automation2Workflow[];
|
||||
workflows: WorkflowDefinition[];
|
||||
currentWorkflowId: string | null;
|
||||
onWorkflowSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
onSave: () => void;
|
||||
onExecute: () => void;
|
||||
onWorkflowSettings?: () => void;
|
||||
onToggleChat?: () => void;
|
||||
onToggleWorkspacePanel?: () => void;
|
||||
workspacePanelOpen?: boolean;
|
||||
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;
|
||||
|
|
@ -33,8 +90,11 @@ interface CanvasHeaderProps {
|
|||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||
templateSaving?: boolean;
|
||||
onNewFromTemplate?: () => void;
|
||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
||||
onAutoLayout?: () => 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;
|
||||
}
|
||||
|
||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||
|
|
@ -45,17 +105,23 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
|
|||
};
|
||||
}
|
||||
|
||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||
const _tb = 'secondary' as const;
|
||||
const _ts = 'sm' as const;
|
||||
|
||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||
workflows,
|
||||
currentWorkflowId,
|
||||
onWorkflowSelect,
|
||||
onNew,
|
||||
onSave,
|
||||
onExecute,
|
||||
onWorkflowSettings,
|
||||
onToggleChat,
|
||||
onToggleWorkspacePanel,
|
||||
workspacePanelOpen,
|
||||
saving,
|
||||
executing,
|
||||
hasNodes,
|
||||
executeBlockedReason,
|
||||
onExecuteBlockedClick,
|
||||
executeResult,
|
||||
versions,
|
||||
currentVersionId,
|
||||
|
|
@ -68,10 +134,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
onSaveAsTemplate,
|
||||
templateSaving,
|
||||
onNewFromTemplate,
|
||||
onWorkflowRename,
|
||||
onAutoLayout,
|
||||
verboseSchema,
|
||||
onVerboseSchemaChange,
|
||||
canvasEdit,
|
||||
}) => {
|
||||
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';
|
||||
|
|
@ -83,38 +151,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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]);
|
||||
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||
const zoomMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (editingName && nameInputRef.current) {
|
||||
nameInputRef.current.focus();
|
||||
nameInputRef.current.select();
|
||||
}
|
||||
}, [editingName]);
|
||||
const zp = canvasEdit?.zoomPercent;
|
||||
if (zp !== undefined) setZoomInputDraft(String(zp));
|
||||
}, [canvasEdit?.zoomPercent]);
|
||||
|
||||
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);
|
||||
|
|
@ -130,185 +180,376 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<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 }}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
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.'));
|
||||
|
||||
{/* 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
|
||||
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"
|
||||
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
|
||||
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 ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||
<div className={styles.canvasHeaderSplitPair}>
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
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')}
|
||||
/>
|
||||
{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"
|
||||
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)' }}
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
onClick={() => {
|
||||
onNewFromTemplate();
|
||||
setNewMenuOpen(false);
|
||||
}}
|
||||
role="menuitem"
|
||||
>
|
||||
{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>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={onSave}
|
||||
disabled={saving || !hasNodes}
|
||||
{_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')}
|
||||
>
|
||||
{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' }}>
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||
disabled={templateSaving}
|
||||
title={t('Als Vorlage speichern')}
|
||||
className={styles.canvasHeaderZoomChevronBtn}
|
||||
onClick={() => setZoomMenuOpen((p) => !p)}
|
||||
aria-label={t('Zoom-Voreinstellungen')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={zoomMenuOpen}
|
||||
title={t('Zoom-Voreinstellungen')}
|
||||
>
|
||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
||||
<FaCaretDown aria-hidden />
|
||||
</button>
|
||||
{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) => (
|
||||
{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) => (
|
||||
<button
|
||||
key={s}
|
||||
key={pct}
|
||||
type="button"
|
||||
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 }}
|
||||
className={styles.canvasHeaderMenuItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
canvasEdit.onZoomPercentCommit(pct);
|
||||
setZoomMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
{scopeLabels[s]}
|
||||
{pct}%
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<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
|
||||
type="button"
|
||||
className={styles.canvasHeaderGhostIconBtn}
|
||||
onClick={canvasEdit.onZoomIn}
|
||||
title={t('Vergrößern')}
|
||||
aria-label={t('Vergrößern')}
|
||||
>
|
||||
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Version Selector */}
|
||||
{currentWorkflowId && versions && versions.length > 0 && (
|
||||
<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>
|
||||
<div className={styles.canvasHeaderVersionRow}>
|
||||
<span className={styles.canvasHeaderVersionLabel}>{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) => (
|
||||
|
|
@ -318,100 +559,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
|||
))}
|
||||
</select>
|
||||
<span
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 10,
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
background: badge.color + '22',
|
||||
color: badge.color,
|
||||
}}
|
||||
className={styles.canvasHeaderVersionBadge}
|
||||
style={
|
||||
{
|
||||
'--canvasHeaderBadgeBg': `${badge.color}22`,
|
||||
'--canvasHeaderBadgeFg': badge.color,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaCloudUploadAlt}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
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"
|
||||
className={styles.retryButton}
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaCloudDownloadAlt}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
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"
|
||||
className={styles.retryButton}
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaArchive}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={() => onArchiveVersion(currentVersion.id)}
|
||||
disabled={versionLoading}
|
||||
title={t('Version archivieren')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
<FaArchive style={{ marginRight: 4 }} />
|
||||
Archiv
|
||||
</button>
|
||||
{t('Archiv')}
|
||||
</Button>
|
||||
)}
|
||||
{onCreateDraft && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
variant={_tb}
|
||||
size={_ts}
|
||||
icon={FaPlus}
|
||||
className={styles.canvasHeaderVersionAction}
|
||||
onClick={onCreateDraft}
|
||||
disabled={versionLoading}
|
||||
title={t('Neuen Entwurf erstellen')}
|
||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
||||
>
|
||||
+ Entwurf
|
||||
</button>
|
||||
{t('+ Entwurf')}
|
||||
</Button>
|
||||
)}
|
||||
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
|
||||
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{executeResult && (
|
||||
<div
|
||||
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)',
|
||||
}}
|
||||
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
executeResult.warning ? (
|
||||
<>{executeResult.warning}</>
|
||||
) : (
|
||||
<>{t('Ausführung abgeschlossen')}</>
|
||||
)
|
||||
) : executeResult.paused ? (
|
||||
<>
|
||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
||||
Task zu bearbeiten.
|
||||
{t('Workflow pausiert. Öffne ')}
|
||||
<strong>{t('Workflows/Tasks')}</strong>
|
||||
{t(' in der Sidebar, um den Task zu bearbeiten.')}
|
||||
</>
|
||||
) : (
|
||||
<>✗ {executeResult.error ?? t('Unbekannter Fehler')}</>
|
||||
<>{executeResult.error ?? t('Unbekannter Fehler')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* EditorChatPanel
|
||||
*
|
||||
* AI Chat sidebar for the GraphicalEditor.
|
||||
* AI Chat sidebar for the WorkflowAutomation editor.
|
||||
* Streams responses via SSE (same pattern as Workspace chat).
|
||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
||||
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||
*/
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
|
|
@ -32,7 +34,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
|||
export interface PendingFile {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
itemType?: 'file' | 'folder';
|
||||
itemType?: 'file' | 'group';
|
||||
}
|
||||
|
||||
export interface EditorDataSource {
|
||||
|
|
@ -87,7 +89,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
|
||||
// Load persisted chat history from the backend whenever the workflow changes.
|
||||
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
||||
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
|
||||
// returned by `GET /api/workflow-automation/{workflowId}/chat/messages`.
|
||||
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||
useEffect(() => {
|
||||
if (!workflowId) {
|
||||
|
|
@ -99,7 +101,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
setHistoryLoading(true);
|
||||
try {
|
||||
const res = await api.get<PersistedEditorChatResponse>(
|
||||
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
||||
`/api/workflow-automation/${workflowId}/chat/messages`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||
|
|
@ -166,7 +168,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
|
||||
const baseURL = api.defaults.baseURL || '';
|
||||
const cleanup = startSseStream({
|
||||
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
||||
url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
|
||||
body,
|
||||
handlers: {
|
||||
onChunk: (event) => {
|
||||
|
|
@ -227,7 +229,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
: m));
|
||||
}
|
||||
try {
|
||||
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
||||
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
|
||||
} catch {
|
||||
}
|
||||
abortRef.current?.();
|
||||
|
|
@ -241,7 +243,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
|||
}, [_handleSend]);
|
||||
|
||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
||||
if (
|
||||
e.dataTransfer.types.includes('application/tree-items') ||
|
||||
e.dataTransfer.types.includes('application/group-id') ||
|
||||
e.dataTransfer.types.includes('application/file-id') ||
|
||||
e.dataTransfer.types.includes('application/file-ids')
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setTreeDropOver(true);
|
||||
|
|
@ -252,6 +259,12 @@ 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();
|
||||
|
|
@ -282,11 +295,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 === 'folder' ? '#e3f2fd' : '#fff3e0',
|
||||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
||||
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
|
||||
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
|
||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
|
||||
}}>
|
||||
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||
{onRemovePendingFile && (
|
||||
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,19 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* EditorWorkflowChatList
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
||||
* GraphicalEditor data instead of the workspace endpoint.
|
||||
* WorkflowAutomation data instead of the workspace endpoint.
|
||||
*/
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { Automation2Workflow } from '../../../api/workflowApi';
|
||||
import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
|
||||
|
||||
interface EditorWorkflowChatListProps {
|
||||
workflows: Automation2Workflow[];
|
||||
workflows: WorkflowDefinition[];
|
||||
currentWorkflowId: string | null;
|
||||
onSelect: (workflowId: string | null) => void;
|
||||
onNew: () => void;
|
||||
|
|
@ -48,7 +50,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.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
|
||||
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
|
||||
return list;
|
||||
}, [workflows, search]);
|
||||
|
||||
|
|
@ -85,7 +87,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
|||
) : (
|
||||
filtered.map((wf) => {
|
||||
const isActive = wf.id === currentWorkflowId;
|
||||
const ts = wf.lastStartedAt || wf.createdAt;
|
||||
const ts = wf.lastStartedAt || wf.sysCreatedAt;
|
||||
return (
|
||||
<div
|
||||
key={wf.id}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,99 @@
|
|||
// 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, useRef } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
|
||||
import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
|
||||
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
|
||||
import styles from './WorkflowFlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { 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;
|
||||
|
|
@ -22,6 +104,38 @@ 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,
|
||||
|
|
@ -32,6 +146,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
onNodeUpdate,
|
||||
instanceId,
|
||||
request,
|
||||
verboseSchema = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
|
|
@ -55,7 +170,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
const updateParam = useCallback(
|
||||
(key: string, value: unknown) => {
|
||||
setParams((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
const next = { ...prev };
|
||||
if (value === undefined) {
|
||||
delete next[key];
|
||||
} else {
|
||||
next[key] = value;
|
||||
}
|
||||
const id = nodeIdRef.current;
|
||||
if (id) {
|
||||
if (notifyParentTimeoutRef.current != null) {
|
||||
|
|
@ -72,11 +192,216 @@ 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 = nodeType.parameters || [];
|
||||
const parameters = sortedParameters;
|
||||
|
||||
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||
const inputPortEntries = Object.entries(inputPortDefs);
|
||||
const outputPortEntries = Object.entries(outputPortDefs);
|
||||
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
|
||||
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
|
|
@ -101,20 +426,316 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
|||
{getLabel(nodeType.description, language)}
|
||||
</p>
|
||||
)}
|
||||
{parameters.map((param: NodeTypeParameter) => {
|
||||
const frontendType = param.frontendType || 'text';
|
||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||
{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>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
// 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/workflowApi';
|
||||
import type { NodeType } from '../../../api/workflowAutomationApi';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import styles from './WorkflowFlowEditor.module.css';
|
||||
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||
|
||||
interface NodeListItemProps {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
|
||||
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
|
||||
import type { NodeType, NodeTypeCategory } from '../../../api/workflowAutomationApi';
|
||||
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||
import { getLabel } from '../nodes/shared/utils';
|
||||
import { NodeListItem } from './NodeListItem';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import styles from './WorkflowFlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -21,7 +23,7 @@ interface NodeSidebarProps {
|
|||
language: string;
|
||||
expandedCategories: Set<string>;
|
||||
onToggleCategory: (id: string) => void;
|
||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
||||
/** Hide palette categories (optional; e.g. feature flags) */
|
||||
excludedCategories?: Set<string>;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* RunTracingPanel
|
||||
*
|
||||
|
|
@ -7,7 +9,7 @@
|
|||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import type { AutoStepLog } from '../../../api/workflowApi';
|
||||
import type { AutoStepLog } from '../../../api/workflowAutomationApi';
|
||||
import api from '../../../api';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
|
|
@ -98,7 +100,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
|||
setLoading(true);
|
||||
try {
|
||||
const data = await request({
|
||||
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
|
||||
url: `/api/workflow-automation/runs/${runId}/steps`,
|
||||
method: 'get',
|
||||
});
|
||||
setSteps(data?.steps || []);
|
||||
|
|
@ -115,7 +117,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
|||
loadSteps();
|
||||
|
||||
const baseUrl = api.defaults.baseURL || '';
|
||||
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
|
||||
const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`;
|
||||
const es = new EventSource(url, { withCredentials: true });
|
||||
eventSourceRef.current = es;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
|
||||
*/
|
||||
|
|
@ -9,8 +11,8 @@ import {
|
|||
type AutoWorkflowTemplate,
|
||||
type AutoTemplateScope,
|
||||
type ApiRequestFunction,
|
||||
} from '../../../api/workflowApi';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
} from '../../../api/workflowAutomationApi';
|
||||
import styles from './WorkflowFlowEditor.module.css';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
interface TemplatePickerProps {
|
||||
|
|
@ -50,7 +52,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
|
|||
setLoading(true);
|
||||
try {
|
||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
||||
const result = await fetchTemplates(request, instanceId, scope);
|
||||
const result = await fetchTemplates(request, scope);
|
||||
setTemplates(Array.isArray(result) ? result : result.items);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* Workflow configuration — primary start kind drives the canvas start node.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
|
||||
import {
|
||||
getPrimaryStartKind,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
/** Vier Einstiege; bei „Immer aktiv“ folgt später die Listener-Konfiguration (E-Mail, Webhook, …). */
|
||||
function _getKindOptions(t: (key: string) => string): { value: string; label: string }[] {
|
||||
return [
|
||||
{ value: 'manual', label: t('Manueller Trigger') },
|
||||
{ value: 'form', label: t('Formular') },
|
||||
{ value: 'schedule', label: t('Zeitplan') },
|
||||
{ value: 'always_on', label: t('Immer aktiv') },
|
||||
];
|
||||
}
|
||||
|
||||
interface WorkflowConfigurationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
invocations: WorkflowEntryPoint[];
|
||||
onApply: (next: WorkflowEntryPoint[]) => void;
|
||||
}
|
||||
|
||||
const _validKinds = ['manual', 'form', 'schedule', 'always_on'];
|
||||
|
||||
function normalizeLoadedKind(k: string): string {
|
||||
if (_validKinds.includes(k)) return k;
|
||||
if (['email', 'webhook', 'event'].includes(k)) return 'always_on';
|
||||
if (k === 'api') return 'manual';
|
||||
return 'manual';
|
||||
}
|
||||
|
||||
export const WorkflowConfigurationModal: React.FC<WorkflowConfigurationModalProps> = ({ open,
|
||||
onClose,
|
||||
invocations,
|
||||
onApply,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const kindOptions = _getKindOptions(t);
|
||||
const [kind, setKind] = useState(() => normalizeLoadedKind(getPrimaryStartKind(invocations)));
|
||||
const [titleDe, setTitleDe] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const k = normalizeLoadedKind(getPrimaryStartKind(invocations));
|
||||
setKind(k);
|
||||
const entry = invocations[0];
|
||||
const entryTitle = entry?.title;
|
||||
if (typeof entryTitle === 'string') setTitleDe(entryTitle);
|
||||
else if (entryTitle && typeof entryTitle === 'object') setTitleDe(entryTitle.de || entryTitle.en || '');
|
||||
else setTitleDe('');
|
||||
}, [open, invocations]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const label =
|
||||
titleDe.trim() || kindOptions.find((o) => o.value === kind)?.label || t('Start');
|
||||
const next = buildInvocationsForPrimaryKind(kind, invocations, label);
|
||||
onApply(next);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="wf-cfg-title">
|
||||
<div className={styles.workflowModal}>
|
||||
<h3 id="wf-cfg-title" className={styles.workflowModalTitle}>
|
||||
{t('Workflow-Konfiguration')}
|
||||
</h3>
|
||||
<p className={styles.workflowModalHint}>
|
||||
{t(
|
||||
'Legen Sie fest, wie dieser Workflow gestartet werden soll. Die Start-Node im Editor passt sich dem gewählten Einstieg an (z. B. Formular-Felder auf der Start-Node bearbeiten).'
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className={styles.workflowModalLabel} htmlFor="wf-start-title">
|
||||
{t('Titel der Start Node')}
|
||||
</label>
|
||||
<input
|
||||
id="wf-start-title"
|
||||
className={styles.workflowModalInput}
|
||||
value={titleDe}
|
||||
onChange={(e) => setTitleDe(e.target.value)}
|
||||
placeholder={t('z.B. Angebot anlegen')}
|
||||
/>
|
||||
|
||||
<div className={styles.workflowModalRadioGroup} role="radiogroup" aria-label={t('Einstiegsart')}>
|
||||
{kindOptions.map((o) => (
|
||||
<label key={o.value} className={styles.workflowModalRadio}>
|
||||
<input
|
||||
type="radio"
|
||||
name="kind"
|
||||
value={o.value}
|
||||
checked={kind === o.value}
|
||||
onChange={() => setKind(o.value)}
|
||||
/>
|
||||
{o.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.workflowModalActions}>
|
||||
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
|
||||
{t('Abbrechen')}
|
||||
</button>
|
||||
<button type="submit" className={styles.workflowModalBtnPrimary}>
|
||||
{t('Übernehmen')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Automation2 Flow Editor Styles
|
||||
* Workflow Flow Editor Styles
|
||||
* Sidebar with node list + canvas area.
|
||||
*/
|
||||
|
||||
|
|
@ -246,6 +246,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--canvas-bg, #fafafa);
|
||||
}
|
||||
|
||||
|
|
@ -254,6 +255,385 @@
|
|||
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 {
|
||||
|
|
@ -265,22 +645,183 @@
|
|||
|
||||
.canvasArea {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
overflow-x: visible;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.canvasDropZone {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
/* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
|
||||
overflow: visible;
|
||||
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;
|
||||
|
|
@ -476,6 +1017,8 @@
|
|||
|
||||
.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) {
|
||||
|
|
@ -507,20 +1050,45 @@
|
|||
cursor: copy;
|
||||
}
|
||||
|
||||
/* Node Config Panel */
|
||||
/* Shell: stretches to full canvas-area height so inner `.nodeConfigPanel` can scroll. */
|
||||
.nodeConfigPanelWrap {
|
||||
flex-shrink: 0;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Node Config Panel
|
||||
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
|
||||
* pair acts as a safety net so long unbreakable strings (type names like
|
||||
* `List[ActionDocument]`, hashed IDs, refs like `← node.path → field`) can
|
||||
* never push content out of the panel frame. Children rely on this; e.g.
|
||||
* `RequiredAttributePicker` lays out label/badge so the badge wraps below
|
||||
* a long label rather than escaping to the right.
|
||||
*/
|
||||
.nodeConfigPanel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
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 {
|
||||
|
|
@ -547,6 +1115,8 @@
|
|||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.4;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.nodeConfigPanel label {
|
||||
|
|
@ -572,9 +1142,12 @@
|
|||
min-height: 60px;
|
||||
}
|
||||
|
||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips */
|
||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
|
||||
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
|
||||
.nodeConfigPanel
|
||||
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
|
||||
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
|
||||
[data-accordion-header]
|
||||
):not([data-schedule-day]) {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
|
|
@ -667,6 +1240,12 @@
|
|||
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;
|
||||
|
|
@ -1257,24 +1836,6 @@
|
|||
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;
|
||||
|
|
@ -1284,53 +1845,112 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Data Picker */
|
||||
/* Data Picker — rendered with createPortal(document.body) so it is not affected
|
||||
by .nodeConfigPanel’s generic CTA `button` styles. */
|
||||
|
||||
.dataPickerOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: 11000;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dataPickerModal {
|
||||
background: var(--bg-primary, #fff);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
max-width: 420px;
|
||||
max-height: 80vh;
|
||||
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);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dataPickerHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0 0.25rem;
|
||||
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;
|
||||
cursor: pointer;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.dataPickerClose:hover {
|
||||
color: var(--text-primary, #333);
|
||||
background: var(--bg-hover, #e9ecef);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
border-color: var(--border-color, #b8b8b8);
|
||||
}
|
||||
|
||||
.dataPickerBody {
|
||||
|
|
@ -1345,24 +1965,35 @@
|
|||
}
|
||||
|
||||
.dataPickerNodeSection {
|
||||
margin-bottom: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Expandable source row: neutral “list row”, not a primary CTA. */
|
||||
.dataPickerNodeHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
background: none;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--bg-secondary, #f4f5f7);
|
||||
border: 1px solid var(--border-color, #dde1e5);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-size: 0.85rem;
|
||||
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, #f5f5f5);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover, #e9ebef);
|
||||
border-color: var(--border-color, #c8cfd6);
|
||||
}
|
||||
|
||||
.dataPickerNodeHeader:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #4a6fa5);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.dataPickerExpandIcon {
|
||||
|
|
@ -1401,6 +2032,105 @@
|
|||
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;
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Automation2FlowEditor
|
||||
* WorkflowFlowEditor
|
||||
*
|
||||
* n8n-style flow builder with backend-driven node list.
|
||||
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
|
|
@ -22,29 +24,35 @@ import {
|
|||
archiveVersion,
|
||||
createTemplateFromWorkflow,
|
||||
copyTemplate,
|
||||
importWorkflowFromFile,
|
||||
WORKFLOW_FILE_EXTENSION,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
type Automation2Workflow,
|
||||
type WorkflowGraph,
|
||||
type WorkflowDefinition,
|
||||
type ExecuteGraphResponse,
|
||||
type WorkflowEntryPoint,
|
||||
type AutoVersion,
|
||||
type AutoTemplateScope,
|
||||
} from '../../../api/workflowApi';
|
||||
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||
} from '../../../api/workflowAutomationApi';
|
||||
import {
|
||||
FlowCanvas,
|
||||
type CanvasNode,
|
||||
type CanvasConnection,
|
||||
type CanvasStickyNote,
|
||||
type FlowCanvasHandle,
|
||||
type FlowCanvasViewportEditState,
|
||||
} 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 } from '../nodes/shared/graphUtils';
|
||||
import {
|
||||
syncCanvasStartNode,
|
||||
buildInvocationsForPrimaryKind,
|
||||
} from '../nodes/runtime/workflowStartSync';
|
||||
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
|
||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
|
||||
import { usePrompt } from '../../../hooks/usePrompt';
|
||||
import { EditorChatPanel } from './EditorChatPanel';
|
||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||
|
|
@ -52,16 +60,28 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
|
|||
import { RunTracingPanel } from './RunTracingPanel';
|
||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
import styles from './WorkflowFlowEditor.module.css';
|
||||
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
||||
const LOG = '[WorkflowEditor]';
|
||||
|
||||
interface Automation2FlowEditorProps {
|
||||
const CANVAS_HISTORY_MAX = 50;
|
||||
|
||||
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
|
||||
return {
|
||||
nodes: nodes.map((n) => ({
|
||||
...n,
|
||||
parameters: n.parameters ? { ...n.parameters } : {},
|
||||
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
|
||||
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
|
||||
})),
|
||||
connections: connections.map((c) => ({ ...c })),
|
||||
};
|
||||
}
|
||||
|
||||
interface WorkflowFlowEditorProps {
|
||||
instanceId: string;
|
||||
mandateId?: string;
|
||||
language?: string;
|
||||
|
|
@ -75,7 +95,7 @@ interface Automation2FlowEditorProps {
|
|||
onSourcesChanged?: () => void;
|
||||
}
|
||||
|
||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
|
||||
export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
|
||||
mandateId,
|
||||
language = 'de',
|
||||
initialWorkflowId,
|
||||
|
|
@ -93,24 +113,38 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||
);
|
||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||
const [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<Automation2Workflow[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
||||
);
|
||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
|
||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||
|
|
@ -122,10 +156,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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; }
|
||||
|
|
@ -133,6 +171,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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(() => {
|
||||
|
|
@ -170,7 +217,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
document.body.style.userSelect = 'none';
|
||||
}, [leftPanelWidth, sidebarWidth]);
|
||||
|
||||
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
||||
const startNodeTypeIds = useMemo(
|
||||
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
|
||||
[nodeTypes]
|
||||
);
|
||||
const hasCanvasStartNode = useMemo(
|
||||
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
|
||||
[canvasNodes, startNodeTypeIds]
|
||||
);
|
||||
const missingStartNodeBlocking = useMemo(
|
||||
() => canvasNodes.length > 0 && !hasCanvasStartNode,
|
||||
[canvasNodes.length, hasCanvasStartNode]
|
||||
);
|
||||
|
||||
const nodeOutputsPreview = useMemo(
|
||||
() =>
|
||||
|
|
@ -178,26 +236,92 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
[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: 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;
|
||||
(
|
||||
graph: WorkflowGraph | null | undefined,
|
||||
wfInvocations: WorkflowEntryPoint[] | undefined,
|
||||
opts?: { skipHistory?: boolean }
|
||||
) => {
|
||||
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
|
||||
pushCanvasHistoryPastFromCurrent();
|
||||
}
|
||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
||||
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
|
||||
setCanvasNodes(synced.nodes);
|
||||
setCanvasConnections(synced.connections);
|
||||
setInvocations(wfInvocations ?? []);
|
||||
const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
|
||||
const { nodes, connections } = fromApiGraph(g, nodeTypes);
|
||||
setCanvasNodes(nodes);
|
||||
setCanvasConnections(connections);
|
||||
},
|
||||
[nodeTypes, language, t]
|
||||
[nodeTypes, pushCanvasHistoryPastFromCurrent]
|
||||
);
|
||||
|
||||
const handleFromApiGraph = useCallback(
|
||||
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||
(graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||
applyGraphWithSync(graph, wfInvocations);
|
||||
},
|
||||
[applyGraphWithSync]
|
||||
|
|
@ -209,11 +333,31 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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, instanceId, graph, currentWorkflowId ?? undefined, {
|
||||
const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, {
|
||||
...(ep ? { entryPointId: ep } : {}),
|
||||
});
|
||||
setExecuteResult(result);
|
||||
|
|
@ -226,7 +370,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setExecuting(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||
|
|
@ -234,11 +378,41 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
||||
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) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
const updated = await updateWorkflow(request, currentWorkflowId, {
|
||||
graph,
|
||||
invocations,
|
||||
targetFeatureInstanceId,
|
||||
});
|
||||
setInvocations(updated.invocations ?? []);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
} else {
|
||||
const label = await promptInput(t('Workflow-Name:'), {
|
||||
title: t('Workflow speichern'),
|
||||
|
|
@ -249,40 +423,64 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
const created = await createWorkflow(request, instanceId, {
|
||||
const created = await createWorkflow(request, {
|
||||
label: label.trim() || t('Neuer Workflow'),
|
||||
graph,
|
||||
invocations,
|
||||
targetFeatureInstanceId,
|
||||
mandateId,
|
||||
});
|
||||
setCurrentWorkflowId(created.id);
|
||||
if (created.invocations?.length) setInvocations(created.invocations);
|
||||
setInvocations(created.invocations ?? []);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
setExecuteResult(_buildSaveResult());
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
||||
}, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
async (workflowId: string) => {
|
||||
try {
|
||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||
const wf = await fetchWorkflow(request, 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, instanceId, handleFromApiGraph, applyGraphWithSync]
|
||||
[request, handleFromApiGraph, applyGraphWithSync, t]
|
||||
);
|
||||
|
||||
const handleWorkflowSelect = useCallback(
|
||||
|
|
@ -291,7 +489,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
if (workflowId) handleLoad(workflowId);
|
||||
else {
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||
}
|
||||
},
|
||||
[handleLoad, applyGraphWithSync, t]
|
||||
|
|
@ -300,36 +498,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
const handleNew = useCallback(() => {
|
||||
setCurrentWorkflowId(null);
|
||||
setExecuteResult(null);
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||
}, [applyGraphWithSync, t]);
|
||||
|
||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
setCanvasNodes((prev) => {
|
||||
const nextNodes = prev.map((n) => {
|
||||
if (n.id !== nodeId) return n;
|
||||
const next = { ...n, parameters };
|
||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||
const cases = (parameters.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
const newCount = switchOutputCountFromCases(parameters.cases);
|
||||
next.outputs = newCount;
|
||||
setCanvasConnections((conns) =>
|
||||
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
});
|
||||
return nextNodes;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) =>
|
||||
prev.map((n) => {
|
||||
setCanvasNodes((prev) => {
|
||||
const nextNodes = 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 cases = (merged.cases as unknown[]) ?? [];
|
||||
next.outputs = Math.max(1, cases.length);
|
||||
const newCount = switchOutputCountFromCases(merged.cases);
|
||||
next.outputs = newCount;
|
||||
setCanvasConnections((conns) =>
|
||||
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
);
|
||||
});
|
||||
return nextNodes;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNodeUpdate = useCallback(
|
||||
|
|
@ -341,24 +547,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
[]
|
||||
);
|
||||
|
||||
const handleApplyWorkflowConfiguration = useCallback(
|
||||
(next: WorkflowEntryPoint[]) => {
|
||||
setInvocations(next);
|
||||
setCanvasNodes((nodes) => {
|
||||
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
|
||||
setCanvasConnections(r.connections);
|
||||
return r.nodes;
|
||||
});
|
||||
},
|
||||
[canvasConnections, nodeTypes, language]
|
||||
);
|
||||
|
||||
const loadNodeTypes = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchNodeTypes(request, instanceId, language);
|
||||
const data = await fetchNodeTypes(request, mandateId || '', language);
|
||||
setNodeTypes(data.nodeTypes);
|
||||
setCategories(data.categories);
|
||||
if (data.portTypeCatalog) {
|
||||
|
|
@ -366,6 +559,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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([]);
|
||||
|
|
@ -373,17 +568,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, language, request]);
|
||||
}, [language, request]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const result = await fetchWorkflows(request, instanceId);
|
||||
const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
|
||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadWorkflows failed`, e);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
}, [request, mandateId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodeTypes();
|
||||
|
|
@ -393,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
useEffect(() => {
|
||||
setCanvasStickyNotes([]);
|
||||
}, [currentWorkflowId]);
|
||||
|
||||
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
||||
|
|
@ -403,17 +601,34 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
useEffect(() => {
|
||||
if (loading || nodeTypes.length === 0) return;
|
||||
if (currentWorkflowId || initialWorkflowId) return;
|
||||
if (canvasNodes.length > 0) return;
|
||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
||||
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,
|
||||
});
|
||||
}, [
|
||||
loading,
|
||||
nodeTypes.length,
|
||||
currentWorkflowId,
|
||||
initialWorkflowId,
|
||||
canvasNodes.length,
|
||||
canvasConnections.length,
|
||||
invocations.length,
|
||||
applyGraphWithSync,
|
||||
t,
|
||||
]);
|
||||
|
||||
const toggleCategory = useCallback((id: string) => {
|
||||
|
|
@ -427,7 +642,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
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)}`;
|
||||
|
|
@ -453,17 +667,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!instanceId || !currentWorkflowId) {
|
||||
if (!currentWorkflowId) {
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const v = await fetchVersions(request, instanceId, currentWorkflowId);
|
||||
const v = await fetchVersions(request, currentWorkflowId);
|
||||
setVersions(v);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadVersions failed`, e);
|
||||
}
|
||||
}, [instanceId, currentWorkflowId, request]);
|
||||
}, [currentWorkflowId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
loadVersions();
|
||||
|
|
@ -484,10 +698,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
const handlePublishVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await publishVersion(request, instanceId, versionId);
|
||||
await publishVersion(request, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
|
|
@ -495,15 +708,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
[request, loadVersions]
|
||||
);
|
||||
|
||||
const handleUnpublishVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await unpublishVersion(request, instanceId, versionId);
|
||||
await unpublishVersion(request, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
|
|
@ -511,15 +723,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
[request, loadVersions]
|
||||
);
|
||||
|
||||
const handleArchiveVersion = useCallback(
|
||||
async (versionId: string) => {
|
||||
if (!instanceId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
await archiveVersion(request, instanceId, versionId);
|
||||
await archiveVersion(request, versionId);
|
||||
await loadVersions();
|
||||
} catch (e: unknown) {
|
||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
|
|
@ -527,14 +738,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setVersionLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, loadVersions]
|
||||
[request, loadVersions]
|
||||
);
|
||||
|
||||
const handleCreateDraft = useCallback(async () => {
|
||||
if (!instanceId || !currentWorkflowId) return;
|
||||
if (!currentWorkflowId) return;
|
||||
setVersionLoading(true);
|
||||
try {
|
||||
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
|
||||
const draft = await createDraftVersion(request, currentWorkflowId);
|
||||
await loadVersions();
|
||||
setCurrentVersionId(draft.id);
|
||||
} catch (e: unknown) {
|
||||
|
|
@ -542,16 +753,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
} finally {
|
||||
setVersionLoading(false);
|
||||
}
|
||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
||||
}, [request, currentWorkflowId, loadVersions]);
|
||||
|
||||
// Template: save current workflow as template
|
||||
const [templateSaving, setTemplateSaving] = useState(false);
|
||||
const handleSaveAsTemplate = useCallback(
|
||||
async (scope: AutoTemplateScope) => {
|
||||
if (!instanceId || !currentWorkflowId) return;
|
||||
if (!currentWorkflowId) return;
|
||||
setTemplateSaving(true);
|
||||
try {
|
||||
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
|
||||
await createTemplateFromWorkflow(request, 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) });
|
||||
|
|
@ -559,16 +770,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setTemplateSaving(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, currentWorkflowId]
|
||||
[request, 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, instanceId, templateId);
|
||||
const wf = await copyTemplate(request, templateId);
|
||||
setWorkflows((prev) => [...prev, wf]);
|
||||
setCurrentWorkflowId(wf.id);
|
||||
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
||||
|
|
@ -577,21 +787,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
[request, instanceId, handleFromApiGraph]
|
||||
[request, handleFromApiGraph]
|
||||
);
|
||||
|
||||
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
||||
try {
|
||||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
||||
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
|
||||
} catch (e: unknown) {
|
||||
console.error(`${LOG} rename failed`, e);
|
||||
}
|
||||
}, [request, instanceId]);
|
||||
|
||||
const handleAutoLayout = useCallback(() => {
|
||||
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
|
||||
}, [canvasConnections]);
|
||||
|
||||
|
||||
|
||||
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||
|
||||
|
|
@ -633,7 +834,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
language={language}
|
||||
expandedCategories={expandedCategories}
|
||||
onToggleCategory={toggleCategory}
|
||||
excludedCategories={sidebarExcludedCategories}
|
||||
style={_sidebarStyle}
|
||||
/>
|
||||
);
|
||||
|
|
@ -641,15 +841,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
|
||||
const configurableSelected =
|
||||
selectedNode &&
|
||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
|
||||
selectedNode.type.startsWith(p)
|
||||
);
|
||||
[
|
||||
'input.',
|
||||
'ai.',
|
||||
'email.',
|
||||
'sharepoint.',
|
||||
'clickup.',
|
||||
'trigger.',
|
||||
'flow.',
|
||||
'file.',
|
||||
'trustee.',
|
||||
'context.',
|
||||
'data.',
|
||||
'redmine.',
|
||||
].some((p) => selectedNode.type.startsWith(p));
|
||||
|
||||
const canvasHeaderEdit = useMemo(
|
||||
() => ({
|
||||
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
|
||||
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
|
||||
connectionSelected: canvasViewportEdit.connectionSelected,
|
||||
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
|
||||
connectionToolActive: canvasConnectionToolActive,
|
||||
canUndo: canCanvasUndo,
|
||||
canRedo: canCanvasRedo,
|
||||
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
|
||||
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
|
||||
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
|
||||
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
|
||||
onResetView: () => flowCanvasRef.current?.resetView(),
|
||||
onUndo: undoCanvasEdit,
|
||||
onRedo: redoCanvasEdit,
|
||||
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
|
||||
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
|
||||
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
|
||||
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
|
||||
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
|
||||
}),
|
||||
[
|
||||
canvasViewportEdit,
|
||||
canvasConnectionToolActive,
|
||||
canCanvasUndo,
|
||||
canCanvasRedo,
|
||||
undoCanvasEdit,
|
||||
redoCanvasEdit,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
||||
{leftPanelOpen && (<>
|
||||
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
||||
<div
|
||||
data-suppress-flow-node-hotkeys=""
|
||||
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
|
||||
>
|
||||
<div className={styles.rightTabBar}>
|
||||
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
||||
<button
|
||||
|
|
@ -699,7 +945,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
activeTab={udbTab as UdbTab}
|
||||
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
||||
hideTabs={['chats']}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileSelect={async (fileId, fileName) => {
|
||||
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
|
||||
try {
|
||||
const result = await importWorkflowFromFile(request, { fileId });
|
||||
await loadWorkflows();
|
||||
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
||||
} catch (e) {
|
||||
console.error('[workflowAutomation] workflow file import failed', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
onFileSelect?.(fileId, fileName);
|
||||
}}
|
||||
onSourcesChanged={onSourcesChanged}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -717,11 +975,24 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onNew={handleNew}
|
||||
onSave={handleSave}
|
||||
onExecute={handleExecute}
|
||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
||||
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
|
||||
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
|
||||
workspacePanelOpen={leftPanelOpen}
|
||||
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}
|
||||
|
|
@ -734,12 +1005,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onSaveAsTemplate={handleSaveAsTemplate}
|
||||
templateSaving={templateSaving}
|
||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||
onWorkflowRename={handleWorkflowRename}
|
||||
onAutoLayout={handleAutoLayout}
|
||||
verboseSchema={verboseSchema}
|
||||
onVerboseSchemaChange={setVerboseSchema}
|
||||
canvasEdit={canvasHeaderEdit}
|
||||
/>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
|
||||
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
|
||||
<FlowCanvas
|
||||
ref={flowCanvasRef}
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeTypes={nodeTypes}
|
||||
|
|
@ -750,10 +1023,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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 && (
|
||||
<Automation2DataFlowProvider
|
||||
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
|
||||
<WorkflowDataFlowProvider
|
||||
node={selectedNode}
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
|
|
@ -762,6 +1057,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
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}
|
||||
|
|
@ -772,15 +1071,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
onNodeUpdate={handleNodeUpdate}
|
||||
instanceId={instanceId}
|
||||
request={request}
|
||||
verboseSchema={verboseSchema}
|
||||
/>
|
||||
</Automation2DataFlowProvider>
|
||||
</WorkflowDataFlowProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Nodes + Tracing tabs */}
|
||||
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
|
||||
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
|
||||
<div
|
||||
data-suppress-flow-node-hotkeys=""
|
||||
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
|
||||
>
|
||||
<div className={styles.rightTabBar}>
|
||||
<button
|
||||
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
||||
|
|
@ -813,12 +1117,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
</div>
|
||||
|
||||
<PromptDialog />
|
||||
<WorkflowConfigurationModal
|
||||
open={workflowSettingsOpen}
|
||||
onClose={() => setWorkflowSettingsOpen(false)}
|
||||
invocations={invocations}
|
||||
onApply={handleApplyWorkflowConfiguration}
|
||||
/>
|
||||
<TemplatePicker
|
||||
open={templatePickerOpen}
|
||||
onClose={() => setTemplatePickerOpen(false)}
|
||||
|
|
@ -830,4 +1128,4 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
|||
);
|
||||
};
|
||||
|
||||
export default Automation2FlowEditor;
|
||||
export default WorkflowFlowEditor;
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
|
||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||
export { FlowCanvas } from './editor/FlowCanvas';
|
||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
|
||||
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
|
||||
export { 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';
|
||||
|
|
|
|||
106
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal file
106
src/components/FlowEditor/nodes/form/FormFieldOptionsEditor.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* One text field per option — the text the end user sees in the dropdown.
|
||||
* Stored as { value, label } with the same string so payload and UI stay in sync.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import type { FormFieldOptionRow } from './formFieldOptionsUtils';
|
||||
|
||||
export interface FormFieldOptionsEditorProps {
|
||||
options: FormFieldOptionRow[];
|
||||
onChange: (next: FormFieldOptionRow[]) => void;
|
||||
className?: string;
|
||||
rowClassName?: string;
|
||||
}
|
||||
|
||||
export const FormFieldOptionsEditor: React.FC<FormFieldOptionsEditorProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
className,
|
||||
rowClassName,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const rootClass = className ?? '';
|
||||
const lineClass = rowClassName ?? '';
|
||||
|
||||
const setOptionText = (idx: number, text: string) => {
|
||||
const next = options.map((o, i) =>
|
||||
i === idx ? { value: text, label: text } : o,
|
||||
);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={rootClass}>
|
||||
<div style={{ fontSize: '0.72rem', color: 'var(--text-secondary, #666)', marginBottom: 4 }}>
|
||||
{t('Auswahloptionen')}
|
||||
</div>
|
||||
{options.map((opt, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={lineClass}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('z.B. On hold')}
|
||||
value={opt.label || opt.value}
|
||||
onChange={(e) => setOptionText(idx, e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 120px',
|
||||
minWidth: 80,
|
||||
padding: '4px 6px',
|
||||
fontSize: '0.8rem',
|
||||
borderRadius: 4,
|
||||
border: '1px solid var(--border-color, #ddd)',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
title={t('Option entfernen')}
|
||||
onClick={() => onChange(options.filter((_, i) => i !== idx))}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-tertiary, #999)',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([...options, { value: '', label: '' }])}
|
||||
style={{
|
||||
marginTop: 2,
|
||||
padding: '4px 10px',
|
||||
fontSize: '0.75rem',
|
||||
borderRadius: 4,
|
||||
border: '1px dashed var(--border-color, #bbb)',
|
||||
background: 'var(--bg-primary, #fff)',
|
||||
color: 'var(--text-secondary, #555)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
+ {t('Option')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,46 +1,31 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Form node config - draggable fields, types, required toggle
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React from 'react';
|
||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||
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 { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||
updateParam,
|
||||
instanceId,
|
||||
request,
|
||||
}) => {
|
||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||
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;
|
||||
|
|
@ -87,20 +72,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
</span>
|
||||
<div className={styles.formFieldInputs}>
|
||||
<input
|
||||
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')}
|
||||
placeholder={t('Bezeichnung')}
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const label = e.target.value;
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
|
|
@ -108,33 +85,22 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
</div>
|
||||
<div className={styles.formFieldRowFooter}>
|
||||
<select
|
||||
value={f.type ?? 'string'}
|
||||
value={f.type ?? 'text'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
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,
|
||||
}),
|
||||
};
|
||||
const type = e.target.value as FormField['type'];
|
||||
const row: FormField = { ...f, type };
|
||||
if (formFieldTypeHasConfigurableOptions(type)) {
|
||||
row.options = normalizeFormFieldOptions(row.options);
|
||||
}
|
||||
next[i] = row;
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
>
|
||||
<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>
|
||||
{fieldTypeOptions.map((ft) => (
|
||||
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className={styles.formFieldRequiredLabel}>
|
||||
<input
|
||||
|
|
@ -157,72 +123,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
|||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
{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>
|
||||
{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);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||
updateParam('fields', [
|
||||
...fields,
|
||||
{
|
||||
name: deriveFormFieldPayloadKey('', fields.length),
|
||||
type: 'text',
|
||||
label: '',
|
||||
required: false,
|
||||
},
|
||||
])
|
||||
}
|
||||
>
|
||||
+ {t('Feld')}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Helpers for optional select/multiselect rows on workflow form field definitions.
|
||||
*/
|
||||
|
||||
export type FormFieldOptionRow = { value: string; label: string };
|
||||
|
||||
/** Field types where the author defines explicit { value, label } choices. */
|
||||
export function formFieldTypeHasConfigurableOptions(typeId: string | undefined): boolean {
|
||||
if (!typeId) return false;
|
||||
return typeId === 'select' || typeId === 'enum';
|
||||
}
|
||||
|
||||
export function normalizeFormFieldOptions(raw: unknown): FormFieldOptionRow[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.map((o, i) => {
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
const r = o as Record<string, unknown>;
|
||||
const value = String(r.value ?? r.id ?? '');
|
||||
const label = String(r.label ?? r.value ?? r.id ?? `Option ${i + 1}`);
|
||||
return { value, label };
|
||||
}
|
||||
const s = String(o ?? '');
|
||||
return { value: s, label: s };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable key for `payload.*` / data refs. From the visible label; empty label → `field_<index>`.
|
||||
*/
|
||||
export function deriveFormFieldPayloadKey(label: string, index: number): string {
|
||||
const trimmed = label.trim();
|
||||
if (!trimmed) return `field_${index + 1}`;
|
||||
const deaccent = trimmed.normalize('NFKD').replace(/[\u0300-\u036f]/g, '');
|
||||
let s = deaccent
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
if (!s) return `field_${index + 1}`;
|
||||
return s;
|
||||
}
|
||||
|
|
@ -1 +1,10 @@
|
|||
// 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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,276 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Backend-driven case list for flow.switch (depends on value dataRef).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { FieldRendererProps } from './index';
|
||||
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
|
||||
import { isRef, type DataRef } from '../shared/dataRef';
|
||||
import { toApiGraph } from '../shared/graphUtils';
|
||||
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export interface SwitchCase {
|
||||
operator: string;
|
||||
value?: string | number | boolean;
|
||||
}
|
||||
|
||||
function normalizeCase(c: unknown): SwitchCase {
|
||||
if (c && typeof c === 'object' && 'operator' in (c as object)) {
|
||||
const o = c as SwitchCase;
|
||||
const v = o.value;
|
||||
const safeValue: string | number | boolean | undefined =
|
||||
typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined;
|
||||
return { operator: o.operator ?? 'eq', value: safeValue };
|
||||
}
|
||||
const fallbackValue: string | number | boolean | undefined =
|
||||
typeof c === 'string' || typeof c === 'number' || typeof c === 'boolean' ? c : undefined;
|
||||
return { operator: 'eq', value: fallbackValue };
|
||||
}
|
||||
|
||||
function operatorsFromCatalog(
|
||||
catalog: Record<string, ConditionOperatorDef[]> | undefined,
|
||||
valueKind: string
|
||||
): ConditionOperatorDef[] {
|
||||
if (!catalog) return [];
|
||||
return catalog[valueKind] ?? catalog.unknown ?? [];
|
||||
}
|
||||
|
||||
function sanitizeCases(cases: SwitchCase[], operators: ConditionOperatorDef[]): SwitchCase[] {
|
||||
if (!operators.length) return cases;
|
||||
return cases.map((c) => {
|
||||
const op = operators.find((o) => o.id === c.operator) ?? operators[0];
|
||||
return {
|
||||
operator: op.id,
|
||||
value: op.needsValue ? c.value ?? '' : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function CaseValueInput({
|
||||
caseItem,
|
||||
opDef,
|
||||
valueKind,
|
||||
onChange,
|
||||
t,
|
||||
}: {
|
||||
caseItem: SwitchCase;
|
||||
opDef: ConditionOperatorDef | undefined;
|
||||
valueKind: string;
|
||||
onChange: (v: string | number) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const valueInput = opDef?.valueInput;
|
||||
const val = caseItem.value;
|
||||
|
||||
if (
|
||||
valueInput?.kind === 'select' ||
|
||||
valueInput?.kind === 'contentType' ||
|
||||
valueInput?.kind === 'outputMode' ||
|
||||
valueInput?.kind === 'language' ||
|
||||
valueInput?.kind === 'mime'
|
||||
) {
|
||||
return (
|
||||
<select
|
||||
value={String(val ?? '')}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('— wählen —')}</option>
|
||||
{(valueInput.options ?? []).map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
type={
|
||||
valueInput?.kind === 'number' || valueKind === 'number'
|
||||
? 'number'
|
||||
: valueInput?.kind === 'date'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={String(val ?? '')}
|
||||
onChange={(e) =>
|
||||
onChange(
|
||||
valueInput?.kind === 'number' || valueKind === 'number'
|
||||
? parseFloat(e.target.value) || 0
|
||||
: e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert')}
|
||||
style={{ flex: 2, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseListEditor: React.FC<FieldRendererProps> = ({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
allParams,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useWorkflowDataFlow();
|
||||
const dependsOn =
|
||||
param.frontendOptions && typeof param.frontendOptions === 'object'
|
||||
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'value')
|
||||
: 'value';
|
||||
|
||||
const valueParam = allParams?.[dependsOn];
|
||||
const ref: DataRef | null = isRef(valueParam) ? valueParam : null;
|
||||
|
||||
const rawCases = Array.isArray(value) ? value : [];
|
||||
const cases: SwitchCase[] = rawCases.map(normalizeCase);
|
||||
|
||||
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
|
||||
const [valueKind, setValueKind] = React.useState('unknown');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const catalog = dataFlow?.conditionOperatorCatalog;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ref) {
|
||||
const ops = operatorsFromCatalog(catalog, 'unknown');
|
||||
setOperators(ops);
|
||||
setValueKind('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
|
||||
if (cancelled) return;
|
||||
setValueKind(vk);
|
||||
setOperators(ops);
|
||||
if (cases.length > 0) {
|
||||
const next = sanitizeCases(cases, ops);
|
||||
if (JSON.stringify(next) !== JSON.stringify(cases)) {
|
||||
onChange(next);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (dataFlow?.instanceId && dataFlow.request) {
|
||||
setLoading(true);
|
||||
fetchConditionMeta(dataFlow.request, {
|
||||
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
||||
nodeId: dataFlow.currentNodeId,
|
||||
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
||||
})
|
||||
.then((meta) => applyMeta(meta.valueKind, meta.operators))
|
||||
.catch(() => applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown')))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
applyMeta('unknown', operatorsFromCatalog(catalog, 'unknown'));
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
|
||||
|
||||
const setCases = (next: SwitchCase[]) => onChange(next);
|
||||
|
||||
const addCase = () => {
|
||||
const opDef = operators[0];
|
||||
setCases([
|
||||
...cases,
|
||||
{
|
||||
operator: opDef?.id ?? 'eq',
|
||||
value: opDef?.needsValue ? (valueKind === 'number' ? 0 : '') : undefined,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
if (!ref) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
|
||||
{t('Zuerst einen Wert im Data Picker wählen')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
{loading && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
|
||||
{t('Lade Operatoren…')}
|
||||
</div>
|
||||
)}
|
||||
{cases.map((c, i) => {
|
||||
const opDef = operators.find((o) => o.id === c.operator) ?? operators[0];
|
||||
const needsValue = opDef?.needsValue ?? true;
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
|
||||
<select
|
||||
value={c.operator}
|
||||
onChange={(e) => {
|
||||
const op = operators.find((o) => o.id === e.target.value);
|
||||
const next = [...cases];
|
||||
next[i] = {
|
||||
operator: e.target.value,
|
||||
value: op?.needsValue ? cases[i]?.value ?? '' : undefined,
|
||||
};
|
||||
setCases(next);
|
||||
}}
|
||||
disabled={loading || operators.length === 0}
|
||||
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
{operators.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{needsValue && (
|
||||
<CaseValueInput
|
||||
caseItem={c}
|
||||
opDef={opDef}
|
||||
valueKind={valueKind}
|
||||
t={t}
|
||||
onChange={(v) => {
|
||||
const next = [...cases];
|
||||
next[i] = { ...next[i], value: v };
|
||||
setCases(next);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCases(cases.filter((_, j) => j !== i))}
|
||||
style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCase}
|
||||
disabled={loading || operators.length === 0}
|
||||
style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}
|
||||
>
|
||||
{t('Fall hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* clickupList — hierarchical ClickUp list picker via connector browse API.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { fetchBrowse, type BrowseEntry } from '../../../../api/workflowAutomationApi';
|
||||
import { fetchClickupList } from '../../../../api/clickupApi';
|
||||
import type { FieldRendererProps } from './index';
|
||||
import {
|
||||
clickupBrowseParentPath,
|
||||
formatListPickerValue,
|
||||
isClickupContainerEntry,
|
||||
isClickupListEntry,
|
||||
parseClickupListPath,
|
||||
resolveListPathFromValue,
|
||||
} from './clickupPathUtils';
|
||||
|
||||
const CLICKUP_PURPLE = '#7B68EE';
|
||||
|
||||
const glassPanel: React.CSSProperties = {
|
||||
marginTop: 6,
|
||||
borderRadius: 10,
|
||||
border: '1px solid rgba(123, 104, 238, 0.35)',
|
||||
background: 'rgba(255, 255, 255, 0.72)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
WebkitBackdropFilter: 'blur(10px)',
|
||||
boxShadow:
|
||||
'0 4px 24px rgba(123, 104, 238, 0.12), 0 0 0 1px rgba(255, 255, 255, 0.5) inset',
|
||||
padding: 8,
|
||||
};
|
||||
|
||||
const glassTrigger: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'stretch',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(123, 104, 238, 0.4)',
|
||||
background: 'linear-gradient(135deg, rgba(255,255,255,0.9) 0%, rgba(123,104,238,0.08) 100%)',
|
||||
boxShadow: '0 0 12px rgba(123, 104, 238, 0.15)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
export const ClickUpListPicker: React.FC<FieldRendererProps> = ({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
allParams,
|
||||
instanceId,
|
||||
request,
|
||||
onPatchParams,
|
||||
nodeType,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
|
||||
const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
|
||||
const hasConnection = !!connectionReference && typeof connectionReference === 'string';
|
||||
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [browsePath, setBrowsePath] = useState('/');
|
||||
const [items, setItems] = useState<BrowseEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickedLabel, setPickedLabel] = useState<string | null>(null);
|
||||
|
||||
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
|
||||
|
||||
const loadBrowse = useCallback(
|
||||
async (path: string) => {
|
||||
if (!request || !instanceId || !connectionReference) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetchBrowse(request, connectionReference, 'clickup', path);
|
||||
setItems(res.items);
|
||||
setBrowsePath(res.path || path);
|
||||
} catch (err: unknown) {
|
||||
setItems([]);
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[request, instanceId, connectionReference],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelOpen || !hasConnection) return;
|
||||
void loadBrowse(browsePath);
|
||||
}, [panelOpen, hasConnection, browsePath, loadBrowse]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!strVal) {
|
||||
setPickedLabel(null);
|
||||
return;
|
||||
}
|
||||
const pathFromVal = resolveListPathFromValue(strVal, param.name);
|
||||
if (pathFromVal) {
|
||||
const parsed = parseClickupListPath(pathFromVal);
|
||||
if (parsed.listId && request && connectionReference) {
|
||||
let cancelled = false;
|
||||
fetchClickupList(request, connectionReference, parsed.listId)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
const name = typeof data.name === 'string' ? data.name : null;
|
||||
setPickedLabel(name || parsed.listId || strVal);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPickedLabel(parsed.listId || strVal);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setPickedLabel(parsed.listId || strVal);
|
||||
return;
|
||||
}
|
||||
if (param.name === 'listId' && strVal && request && connectionReference) {
|
||||
let cancelled = false;
|
||||
fetchClickupList(request, connectionReference, strVal)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPickedLabel(typeof data.name === 'string' ? data.name : strVal);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPickedLabel(strVal);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
setPickedLabel(strVal);
|
||||
}, [strVal, param.name, request, connectionReference]);
|
||||
|
||||
const shouldPatchTeamId =
|
||||
nodeType === 'clickup.searchTasks' || Object.prototype.hasOwnProperty.call(allParams ?? {}, 'teamId');
|
||||
|
||||
const selectList = useCallback(
|
||||
(entry: BrowseEntry) => {
|
||||
const listPath = entry.path;
|
||||
const stored = formatListPickerValue(listPath, param.name);
|
||||
const { teamId, listId } = parseClickupListPath(listPath);
|
||||
|
||||
if (shouldPatchTeamId && onPatchParams && teamId) {
|
||||
const patch: Record<string, unknown> = { [param.name]: stored };
|
||||
patch.teamId = teamId;
|
||||
onPatchParams(patch);
|
||||
} else {
|
||||
onChange(stored);
|
||||
}
|
||||
|
||||
setPickedLabel(entry.name || listId || stored);
|
||||
setPanelOpen(false);
|
||||
},
|
||||
[param.name, shouldPatchTeamId, onPatchParams, onChange],
|
||||
);
|
||||
|
||||
const navigateInto = useCallback((entry: BrowseEntry) => {
|
||||
if (!isClickupContainerEntry(entry.metadata, entry.isFolder)) return;
|
||||
setBrowsePath(entry.path);
|
||||
}, []);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
setBrowsePath((p) => clickupBrowseParentPath(p));
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
if (shouldPatchTeamId && onPatchParams) {
|
||||
const patch: Record<string, unknown> = { [param.name]: '' };
|
||||
if (nodeType === 'clickup.searchTasks') {
|
||||
patch.teamId = '';
|
||||
}
|
||||
onPatchParams(patch);
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
setPickedLabel(null);
|
||||
}, [shouldPatchTeamId, onPatchParams, onChange, param.name, nodeType]);
|
||||
|
||||
const triggerLabel = strVal
|
||||
? pickedLabel ?? '…'
|
||||
: t('ClickUp-Liste wählen');
|
||||
|
||||
const breadcrumb =
|
||||
browsePath === '/'
|
||||
? t('Workspaces')
|
||||
: browsePath.replace(/^\/team\//, '').replace(/\//g, ' › ');
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
{!request || !instanceId ? (
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
{t('Listen-Browser nicht verfügbar (keine API-Anbindung).')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ ...glassTrigger, opacity: hasConnection ? 1 : 0.55 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasConnection}
|
||||
onClick={() => {
|
||||
if (!hasConnection) return;
|
||||
setPanelOpen((o) => {
|
||||
if (!o) setBrowsePath('/');
|
||||
return !o;
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
padding: '8px 10px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: hasConnection ? 'pointer' : 'not-allowed',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
color: 'var(--text-primary, #334155)',
|
||||
}}
|
||||
>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
{hasConnection ? triggerLabel : t('Zuerst {field} wählen', { field: dependsOn })}
|
||||
</span>
|
||||
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
|
||||
{panelOpen ? '▾' : '▸'}
|
||||
</span>
|
||||
</button>
|
||||
{strVal ? (
|
||||
<button
|
||||
type="button"
|
||||
title={t('Auswahl aufheben')}
|
||||
aria-label={t('Auswahl aufheben')}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearSelection();
|
||||
}}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: 36,
|
||||
border: 'none',
|
||||
borderLeft: '1px solid rgba(123, 104, 238, 0.25)',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
fontSize: 16,
|
||||
lineHeight: 1,
|
||||
color: 'var(--text-secondary, #64748b)',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{panelOpen && hasConnection && (
|
||||
<div style={glassPanel}>
|
||||
{error && (
|
||||
<div style={{ fontSize: 11, color: '#c00', marginBottom: 6 }}>{error}</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{browsePath !== '/' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goUp}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(123, 104, 238, 0.35)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
↑ {t('Hoch')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadBrowse(browsePath)}
|
||||
title={t('Neu laden')}
|
||||
style={{
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(123, 104, 238, 0.35)',
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: '#555', flex: 1, minWidth: 0 }}>
|
||||
{breadcrumb}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
overflow: 'auto',
|
||||
borderRadius: 6,
|
||||
border: '1px solid rgba(123, 104, 238, 0.2)',
|
||||
background: 'rgba(255, 255, 255, 0.85)',
|
||||
}}
|
||||
>
|
||||
{loading && (
|
||||
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>{t('Lade')}</div>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<div style={{ padding: 8, fontSize: 11, color: '#888' }}>
|
||||
{t('Keine Einträge')}
|
||||
</div>
|
||||
)}
|
||||
{!loading &&
|
||||
items.map((item) => {
|
||||
const isList = isClickupListEntry(item.metadata);
|
||||
const canNavigate = isClickupContainerEntry(item.metadata, item.isFolder);
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '4px 8px',
|
||||
borderBottom: '1px solid rgba(123, 104, 238, 0.08)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isList) selectList(item);
|
||||
else if (canNavigate) navigateInto(item);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (isList) selectList(item);
|
||||
else if (canNavigate) navigateInto(item);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
cursor: isList || canNavigate ? 'pointer' : 'default',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
title={isList ? t('Liste wählen') : canNavigate ? t('Öffnen') : undefined}
|
||||
>
|
||||
{isList ? '📋' : canNavigate ? '📁' : '·'} {item.name}
|
||||
</span>
|
||||
{isList && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectList(item)}
|
||||
style={{
|
||||
padding: '2px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${CLICKUP_PURPLE}`,
|
||||
background: CLICKUP_PURPLE,
|
||||
color: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
boxShadow: '0 0 8px rgba(123, 104, 238, 0.45)',
|
||||
}}
|
||||
>
|
||||
{t('Wählen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* Backend-driven condition editor for flow.ifElse (depends on Item dataRef).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { FieldRendererProps } from './index';
|
||||
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
|
||||
import { isRef, type DataRef } from '../shared/dataRef';
|
||||
import { toApiGraph } from '../shared/graphUtils';
|
||||
import { fetchConditionMeta, type ConditionOperatorDef } from '../../../../api/workflowAutomationApi';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
|
||||
export interface StructuredCondition {
|
||||
type: 'condition';
|
||||
operator: string;
|
||||
value?: string | number;
|
||||
/** Legacy — ignored when Item is set */
|
||||
ref?: DataRef | null;
|
||||
}
|
||||
|
||||
function parseCondition(v: unknown): StructuredCondition {
|
||||
if (v && typeof v === 'object' && (v as StructuredCondition).type === 'condition') {
|
||||
const c = v as StructuredCondition;
|
||||
return { type: 'condition', operator: c.operator ?? 'eq', value: c.value };
|
||||
}
|
||||
return { type: 'condition', operator: 'eq', value: '' };
|
||||
}
|
||||
|
||||
function operatorsFromCatalog(
|
||||
catalog: Record<string, ConditionOperatorDef[]> | undefined,
|
||||
valueKind: string
|
||||
): ConditionOperatorDef[] {
|
||||
if (!catalog) return [];
|
||||
return catalog[valueKind] ?? catalog.unknown ?? [];
|
||||
}
|
||||
|
||||
export const ConditionEditor: React.FC<FieldRendererProps> = ({
|
||||
param,
|
||||
value,
|
||||
onChange,
|
||||
allParams,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useWorkflowDataFlow();
|
||||
const dependsOn =
|
||||
param.frontendOptions && typeof param.frontendOptions === 'object'
|
||||
? String((param.frontendOptions as Record<string, unknown>).dependsOn ?? 'Item')
|
||||
: 'Item';
|
||||
|
||||
const itemRef = allParams?.[dependsOn];
|
||||
const ref: DataRef | null = isRef(itemRef) ? itemRef : null;
|
||||
|
||||
const cond = parseCondition(value);
|
||||
const [operators, setOperators] = React.useState<ConditionOperatorDef[]>([]);
|
||||
const [valueKind, setValueKind] = React.useState('unknown');
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
const catalog = dataFlow?.conditionOperatorCatalog;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ref) {
|
||||
setOperators([]);
|
||||
setValueKind('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const applyMeta = (vk: string, ops: ConditionOperatorDef[]) => {
|
||||
if (cancelled) return;
|
||||
setValueKind(vk);
|
||||
setOperators(ops);
|
||||
const valid = ops.some((o) => o.id === cond.operator);
|
||||
if (!valid && ops.length > 0) {
|
||||
const first = ops[0];
|
||||
onChange({
|
||||
type: 'condition',
|
||||
operator: first.id,
|
||||
value: first.needsValue ? cond.value ?? '' : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (dataFlow?.instanceId && dataFlow.request) {
|
||||
setLoading(true);
|
||||
fetchConditionMeta(dataFlow.request, {
|
||||
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
||||
nodeId: dataFlow.currentNodeId,
|
||||
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
||||
})
|
||||
.then((meta) => {
|
||||
applyMeta(meta.valueKind, meta.operators);
|
||||
})
|
||||
.catch(() => {
|
||||
const ops = operatorsFromCatalog(catalog, 'unknown');
|
||||
applyMeta('unknown', ops);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
} else {
|
||||
const ops = operatorsFromCatalog(catalog, 'unknown');
|
||||
applyMeta('unknown', ops);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset operators when Item ref changes
|
||||
}, [ref?.nodeId, JSON.stringify(ref?.path), dataFlow?.currentNodeId, catalog]);
|
||||
|
||||
const currentOp = operators.find((o) => o.id === cond.operator) ?? operators[0];
|
||||
const needsValue = currentOp?.needsValue ?? true;
|
||||
const valueInput = currentOp?.valueInput;
|
||||
|
||||
const setCondition = (next: StructuredCondition) => {
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (!ref) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0 }}>
|
||||
{t('Zuerst ein Item im Data Picker wählen')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleOperatorChange = (opId: string) => {
|
||||
const opDef = operators.find((o) => o.id === opId);
|
||||
setCondition({
|
||||
type: 'condition',
|
||||
operator: opId,
|
||||
value: opDef?.needsValue ? cond.value ?? '' : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleValueChange = (v: string | number) => {
|
||||
const kind = valueInput?.kind;
|
||||
const parsed =
|
||||
kind === 'number' || valueKind === 'number' ? parseFloat(String(v)) || 0 : String(v);
|
||||
setCondition({ type: 'condition', operator: cond.operator, value: parsed });
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>
|
||||
{param.description || param.name}
|
||||
</label>
|
||||
<ConditionRow>
|
||||
<label>{t('Vergleich')}</label>
|
||||
<select
|
||||
value={cond.operator}
|
||||
onChange={(e) => handleOperatorChange(e.target.value)}
|
||||
disabled={loading || operators.length === 0}
|
||||
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
{operators.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</ConditionRow>
|
||||
{loading && (
|
||||
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>{t('Lade Operatoren…')}</div>
|
||||
)}
|
||||
{needsValue && (
|
||||
<ConditionRow>
|
||||
<label>{t('Wert')}</label>
|
||||
{valueInput?.kind === 'select' ||
|
||||
valueInput?.kind === 'contentType' ||
|
||||
valueInput?.kind === 'outputMode' ||
|
||||
valueInput?.kind === 'language' ||
|
||||
valueInput?.kind === 'mime' ? (
|
||||
<select
|
||||
value={String(cond.value ?? '')}
|
||||
onChange={(e) => handleValueChange(e.target.value)}
|
||||
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="">{t('— wählen —')}</option>
|
||||
{(valueInput.options ?? []).map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={
|
||||
valueInput?.kind === 'number'
|
||||
? 'number'
|
||||
: valueInput?.kind === 'date'
|
||||
? 'date'
|
||||
: 'text'
|
||||
}
|
||||
value={String(cond.value ?? '')}
|
||||
onChange={(e) =>
|
||||
handleValueChange(
|
||||
valueInput?.kind === 'number' ? parseFloat(e.target.value) || 0 : e.target.value
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
valueInput?.kind === 'regex' ? t('Regex-Muster') : t('Wert eingeben')
|
||||
}
|
||||
style={{ flex: 1, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
)}
|
||||
</ConditionRow>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConditionRow: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6, fontSize: 12 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,374 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* One place to configure context.setContext rows: target key, then either
|
||||
* upstream picker, a fixed literal, or a human task.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
|
||||
import { DataPicker } from '../shared/DataPicker';
|
||||
import { isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||
import type { FieldRendererProps } from './index';
|
||||
|
||||
type ValueSource = 'pickUpstream' | 'literal' | 'humanTask';
|
||||
|
||||
export interface ContextAssignmentRow {
|
||||
contextKey: string;
|
||||
valueSource: ValueSource;
|
||||
/** Single resolved ref (server resolves { type: ref } to a value). */
|
||||
upstreamRef?: DataRef | SystemVarRef | null;
|
||||
/** Optional dotted path under the picked value, or under the wire payload (expert). */
|
||||
sourcePath?: string;
|
||||
literal?: string;
|
||||
taskTitle?: string;
|
||||
taskDescription?: string;
|
||||
mode?: 'set' | 'setIfEmpty' | 'append' | 'increment';
|
||||
valueType?: string;
|
||||
}
|
||||
|
||||
function defaultRow(): ContextAssignmentRow {
|
||||
return {
|
||||
contextKey: '',
|
||||
valueSource: 'literal',
|
||||
literal: '',
|
||||
mode: 'set',
|
||||
valueType: 'str',
|
||||
};
|
||||
}
|
||||
|
||||
function legacyEntryToRow(
|
||||
e: Record<string, unknown>,
|
||||
globalPick: unknown,
|
||||
): ContextAssignmentRow {
|
||||
const am = String(e.assignmentMode || 'direct');
|
||||
let valueSource: ValueSource = 'literal';
|
||||
if (am === 'fromUpstream') valueSource = 'pickUpstream';
|
||||
else if (am === 'humanTask') valueSource = 'humanTask';
|
||||
|
||||
const sourcePathStr = typeof e.sourcePath === 'string' ? e.sourcePath : '';
|
||||
let upstream: DataRef | SystemVarRef | undefined;
|
||||
if (isRef(e.upstreamRef) || isSystemVar(e.upstreamRef)) {
|
||||
upstream = e.upstreamRef as DataRef | SystemVarRef;
|
||||
} else if (
|
||||
am === 'fromUpstream' &&
|
||||
!sourcePathStr.trim() &&
|
||||
(isRef(globalPick) || isSystemVar(globalPick))
|
||||
) {
|
||||
upstream = globalPick as DataRef | SystemVarRef;
|
||||
}
|
||||
|
||||
return {
|
||||
contextKey: typeof e.contextKey === 'string' ? e.contextKey : typeof e.key === 'string' ? e.key : '',
|
||||
valueSource,
|
||||
upstreamRef: upstream,
|
||||
sourcePath: sourcePathStr,
|
||||
literal: e.literal != null ? String(e.literal) : e.value != null ? String(e.value) : '',
|
||||
taskTitle: typeof e.taskTitle === 'string' ? e.taskTitle : '',
|
||||
taskDescription: typeof e.taskDescription === 'string' ? e.taskDescription : '',
|
||||
mode: (e.mode as ContextAssignmentRow['mode']) || 'set',
|
||||
valueType: typeof e.valueType === 'string' ? e.valueType : typeof e.type === 'string' ? e.type : 'str',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRows(raw: unknown, allParams?: Record<string, unknown>): ContextAssignmentRow[] {
|
||||
if (Array.isArray(raw) && raw.length > 0) {
|
||||
return raw.map((r) => {
|
||||
if (!r || typeof r !== 'object') return defaultRow();
|
||||
const o = r as Record<string, unknown>;
|
||||
let valueSource = o.valueSource as ValueSource | undefined;
|
||||
if (!valueSource && o.assignmentMode === 'fromUpstream') valueSource = 'pickUpstream';
|
||||
else if (!valueSource && o.assignmentMode === 'humanTask') valueSource = 'humanTask';
|
||||
else if (!valueSource) valueSource = 'literal';
|
||||
return {
|
||||
contextKey: typeof o.contextKey === 'string' ? o.contextKey : typeof o.key === 'string' ? o.key : '',
|
||||
valueSource,
|
||||
upstreamRef: (isRef(o.upstreamRef) || isSystemVar(o.upstreamRef) ? o.upstreamRef : undefined) as
|
||||
| DataRef
|
||||
| SystemVarRef
|
||||
| undefined,
|
||||
sourcePath: typeof o.sourcePath === 'string' ? o.sourcePath : '',
|
||||
literal: o.literal != null ? String(o.literal) : o.value != null ? String(o.value) : '',
|
||||
taskTitle: typeof o.taskTitle === 'string' ? o.taskTitle : '',
|
||||
taskDescription: typeof o.taskDescription === 'string' ? o.taskDescription : '',
|
||||
mode: (o.mode as ContextAssignmentRow['mode']) || 'set',
|
||||
valueType: typeof o.valueType === 'string' ? o.valueType : typeof o.type === 'string' ? o.type : 'str',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const g = allParams;
|
||||
if (g && Array.isArray(g.entries) && g.entries.length > 0) {
|
||||
const globalPick = g.upstreamPick;
|
||||
return (g.entries as Record<string, unknown>[]).map((e) => legacyEntryToRow(e, globalPick));
|
||||
}
|
||||
if (g) {
|
||||
const tk = String(g.targetKey || '').trim();
|
||||
const globalPick = g.upstreamPick;
|
||||
if (
|
||||
tk &&
|
||||
globalPick !== undefined &&
|
||||
globalPick !== null &&
|
||||
!(typeof globalPick === 'string' && !globalPick.trim()) &&
|
||||
!(typeof globalPick === 'object' && globalPick !== null && Object.keys(globalPick).length === 0)
|
||||
) {
|
||||
const ups =
|
||||
isRef(globalPick) || isSystemVar(globalPick) ? (globalPick as DataRef | SystemVarRef) : undefined;
|
||||
return [
|
||||
{
|
||||
contextKey: tk,
|
||||
valueSource: 'pickUpstream' as const,
|
||||
upstreamRef: ups,
|
||||
sourcePath: '',
|
||||
literal: '',
|
||||
taskTitle: '',
|
||||
taskDescription: '',
|
||||
mode: 'set',
|
||||
valueType: 'str',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return [defaultRow()];
|
||||
}
|
||||
|
||||
const MODES: Array<{ id: NonNullable<ContextAssignmentRow['mode']>; labelDe: string }> = [
|
||||
{ id: 'set', labelDe: 'setzen' },
|
||||
{ id: 'setIfEmpty', labelDe: 'setzen wenn leer' },
|
||||
{ id: 'append', labelDe: 'anhängen' },
|
||||
{ id: 'increment', labelDe: 'addieren' },
|
||||
];
|
||||
|
||||
const TYPES = ['str', 'int', 'float', 'bool', 'object', 'list'] as const;
|
||||
|
||||
const ROW_BOX: React.CSSProperties = {
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
marginBottom: 8,
|
||||
background: '#fafafa',
|
||||
};
|
||||
|
||||
const CHIP_STYLE: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 8px',
|
||||
background: '#eaf6e8',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
};
|
||||
|
||||
const REMOVE_BTN: React.CSSProperties = {
|
||||
padding: '0 6px',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 3,
|
||||
background: '#fff',
|
||||
color: '#3c763d',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
|
||||
export const ContextAssignmentsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useWorkflowDataFlow();
|
||||
const rows = normalizeRows(value, allParams);
|
||||
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
|
||||
|
||||
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||
const hasSources = sourceIds.some((id) => {
|
||||
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||
return n?.type !== 'trigger.manual';
|
||||
});
|
||||
|
||||
const setRows = (next: ContextAssignmentRow[]) => {
|
||||
onChange(next.length ? next : [defaultRow()]);
|
||||
};
|
||||
|
||||
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
|
||||
const next = [...rows];
|
||||
next[idx] = { ...next[idx], ...patch };
|
||||
setRows(next);
|
||||
};
|
||||
|
||||
const addRow = () => setRows([...rows, defaultRow()]);
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
if (rows.length <= 1) {
|
||||
onChange([defaultRow()]);
|
||||
return;
|
||||
}
|
||||
setRows(rows.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const labelForRef = (ref: DataRef | SystemVarRef): string => {
|
||||
if (isSystemVar(ref)) {
|
||||
return t('System') + `: ${ref.variable}`;
|
||||
}
|
||||
const nodeLabel =
|
||||
dataFlow?.getNodeLabel(
|
||||
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
|
||||
) ?? ref.nodeId;
|
||||
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
|
||||
return pathStr ? `${nodeLabel} → ${pathStr}` : nodeLabel;
|
||||
};
|
||||
|
||||
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
|
||||
if (!isRef(picked) && !isSystemVar(picked)) return;
|
||||
setRow(idx, { upstreamRef: picked });
|
||||
setPickerRow(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
|
||||
{param.description || param.name}
|
||||
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
|
||||
</label>
|
||||
|
||||
{rows.map((row, idx) => (
|
||||
<div key={idx} style={ROW_BOX}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Ziel-Schlüssel im Kontext')}
|
||||
value={row.contextKey}
|
||||
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
|
||||
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
<select
|
||||
value={row.valueSource}
|
||||
onChange={(e) => {
|
||||
const vs = e.target.value as ValueSource;
|
||||
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
|
||||
if (vs === 'literal') patch.upstreamRef = undefined;
|
||||
if (vs === 'pickUpstream') patch.literal = '';
|
||||
setRow(idx, patch);
|
||||
}}
|
||||
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
|
||||
<option value="literal">{t('Fester Wert')}</option>
|
||||
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={row.mode || 'set'}
|
||||
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
|
||||
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.labelDe}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={row.valueType || 'str'}
|
||||
onChange={(e) => setRow(idx, { valueType: e.target.value })}
|
||||
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
>
|
||||
{TYPES.map((tp) => (
|
||||
<option key={tp} value={tp}>
|
||||
{tp}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{row.valueSource === 'pickUpstream' && (
|
||||
<div>
|
||||
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
|
||||
<div style={CHIP_STYLE}>
|
||||
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
|
||||
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerRow(idx)}
|
||||
disabled={!hasSources}
|
||||
style={{
|
||||
marginTop: 4,
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #1c5fb5',
|
||||
background: hasSources ? '#fff' : '#f5f5f5',
|
||||
color: hasSources ? '#1c5fb5' : '#999',
|
||||
cursor: hasSources ? 'pointer' : 'not-allowed',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
|
||||
value={row.sourcePath || ''}
|
||||
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
|
||||
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{row.valueSource === 'literal' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Wert (oder JSON für object/list)')}
|
||||
value={row.literal ?? ''}
|
||||
onChange={(e) => setRow(idx, { literal: e.target.value })}
|
||||
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{row.valueSource === 'humanTask' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Titel der Aufgabe (optional)')}
|
||||
value={row.taskTitle || ''}
|
||||
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
|
||||
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
|
||||
/>
|
||||
<textarea
|
||||
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
|
||||
value={row.taskDescription || ''}
|
||||
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
|
||||
rows={2}
|
||||
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
|
||||
{t('Zuweisung hinzufügen')}
|
||||
</button>
|
||||
|
||||
{dataFlow && pickerRow != null && (
|
||||
<DataPicker
|
||||
open
|
||||
onClose={() => setPickerRow(null)}
|
||||
onPick={(picked) => onPickRef(pickerRow, picked)}
|
||||
availableSourceIds={sourceIds}
|
||||
nodes={dataFlow.nodes}
|
||||
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||
getNodeLabel={dataFlow.getNodeLabel}
|
||||
expectedParamType="Any"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
// Copyright (c) 2026 PowerOn AG
|
||||
// All rights reserved.
|
||||
/**
|
||||
* ContextBuilderRenderer — multi-select context binding for AI nodes.
|
||||
*
|
||||
* Renders a list of DataRef entries (each pointing to an upstream node's output
|
||||
* path). On execution the backend serialises each ref, joins them with double
|
||||
* newlines and prepends the result to the AI prompt.
|
||||
*
|
||||
* Stored value shape:
|
||||
* [ { type: "ref", nodeId: "...", path: [...], expectedType: "..." }, … ]
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
|
||||
import { DataPicker } from '../shared/DataPicker';
|
||||
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
|
||||
import type { FieldRendererProps } from './index';
|
||||
|
||||
function isRefEntry(v: unknown): v is DataRef {
|
||||
return isRef(v);
|
||||
}
|
||||
|
||||
function toRefList(raw: unknown): DataRef[] {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw.filter(isRefEntry);
|
||||
if (isRefEntry(raw)) return [raw];
|
||||
return [];
|
||||
}
|
||||
|
||||
const CHIP_STYLE: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '3px 6px 3px 10px',
|
||||
background: '#eaf6e8',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
marginBottom: 4,
|
||||
};
|
||||
|
||||
const REMOVE_BTN: React.CSSProperties = {
|
||||
padding: '0 5px',
|
||||
border: '1px solid #5cb85c',
|
||||
borderRadius: 3,
|
||||
background: '#fff',
|
||||
color: '#3c763d',
|
||||
cursor: 'pointer',
|
||||
fontSize: 11,
|
||||
marginLeft: 'auto',
|
||||
};
|
||||
|
||||
export const ContextBuilderRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
|
||||
const { t } = useLanguage();
|
||||
const dataFlow = useWorkflowDataFlow();
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const dragIndex = React.useRef<number | null>(null);
|
||||
|
||||
const entries = toRefList(value);
|
||||
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
|
||||
const hasSources = sourceIds.some((id) => {
|
||||
const n = dataFlow?.nodes.find((x) => x.id === id);
|
||||
return n?.type !== 'trigger.manual';
|
||||
});
|
||||
|
||||
const getRefLabel = (ref: DataRef): string => {
|
||||
const nodeLabel =
|
||||
dataFlow?.getNodeLabel(
|
||||
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
|
||||
) ?? ref.nodeId;
|
||||
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
|
||||
return pathStr ? `${nodeLabel} → ${pathStr}` : nodeLabel;
|
||||
};
|
||||
|
||||
const addRef = (picked: DataRef | SystemVarRef) => {
|
||||
if (!isRefEntry(picked)) return;
|
||||
const alreadyIn = entries.some(
|
||||
(e) => e.nodeId === picked.nodeId && e.path.join('.') === picked.path.join('.'),
|
||||
);
|
||||
if (!alreadyIn) {
|
||||
onChange([...entries, picked]);
|
||||
}
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
const removeRef = (index: number) => {
|
||||
const next = entries.filter((_, i) => i !== index);
|
||||
onChange(next.length ? next : undefined);
|
||||
};
|
||||
|
||||
const moveRef = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex === toIndex) return;
|
||||
const next = [...entries];
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>
|
||||
{param.description || param.name}
|
||||
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
|
||||
</label>
|
||||
|
||||
{entries.length > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
{entries.map((ref, i) => (
|
||||
<div
|
||||
key={`${ref.nodeId}-${ref.path.join('.')}`}
|
||||
style={{ ...CHIP_STYLE, cursor: 'grab' }}
|
||||
draggable
|
||||
onDragStart={() => { dragIndex.current = i; }}
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={() => {
|
||||
if (dragIndex.current != null) moveRef(dragIndex.current, i);
|
||||
dragIndex.current = null;
|
||||
}}
|
||||
onDragEnd={() => { dragIndex.current = null; }}
|
||||
>
|
||||
<span style={{ flex: 1, color: '#2d6a2d' }}>
|
||||
{getRefLabel(ref)}
|
||||
</span>
|
||||
<button type="button" style={REMOVE_BTN} onClick={() => removeRef(i)} title={t('Entfernen')}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length === 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
background: '#f8f8f8',
|
||||
border: '1px dashed #ccc',
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{t('Noch keine Quellen gewählt — wähle Daten aus vorherigen Schritten.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
disabled={!hasSources}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderRadius: 4,
|
||||
border: `1px solid #1c5fb5`,
|
||||
background: hasSources ? '#fff' : '#f5f5f5',
|
||||
color: hasSources ? '#1c5fb5' : '#999',
|
||||
cursor: hasSources ? 'pointer' : 'not-allowed',
|
||||
fontSize: 12,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{hasSources ? t('+ Datenquelle hinzufügen …') : t('Keine vorherigen Nodes verfügbar')}
|
||||
</button>
|
||||
|
||||
{dataFlow && (
|
||||
<DataPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
onPick={addRef}
|
||||
availableSourceIds={sourceIds}
|
||||
nodes={dataFlow.nodes}
|
||||
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
|
||||
getNodeLabel={dataFlow.getNodeLabel}
|
||||
expectedParamType={param.type}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue