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
|
.cursorignore
|
||||||
|
|
||||||
# Keep environment template files in config/
|
# Keep environment files in config/ (naming: env-<workflow>.env)
|
||||||
!config/.env.dev
|
!config/env-*.env
|
||||||
!config/.env.int
|
|
||||||
!config/.env.prod
|
tsc-errors.txt
|
||||||
|
scripts/i18n_missing_report.md
|
||||||
31
README.md
31
README.md
|
|
@ -5,9 +5,9 @@
|
||||||
```mermaid
|
```mermaid
|
||||||
graph TB
|
graph TB
|
||||||
%% Environment Files
|
%% Environment Files
|
||||||
ENV_DEV[".env.dev<br/>Development"]
|
ENV_DEV["env-poweron-nyla-dev.env<br/>Development"]
|
||||||
ENV_PROD[".env.prod<br/>Production"]
|
ENV_PROD["env-poweron-nyla-prod.env<br/>Production"]
|
||||||
ENV_INT[".env.int<br/>Integration"]
|
ENV_INT["env-poweron-nyla-int.env<br/>Integration"]
|
||||||
|
|
||||||
%% Configuration System
|
%% Configuration System
|
||||||
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
CONFIG_TS["config.ts<br/>TypeScript Config<br/>(React Frontend)"]
|
||||||
|
|
@ -114,30 +114,25 @@ The app uses a **dual configuration system** to handle environment variables acr
|
||||||
- **Used by:** Express servers and build scripts
|
- **Used by:** Express servers and build scripts
|
||||||
|
|
||||||
### Environment Files
|
### Environment Files
|
||||||
- **`config/.env.dev`** - Development environment variables
|
|
||||||
- **Why:** Separate config for local development with debug settings
|
|
||||||
- **How:** Copied to root `.env` by `npm run dev` command
|
|
||||||
- **Contains:** Local API URLs, debug flags, dev-specific settings
|
|
||||||
|
|
||||||
- **`config/.env.prod`** - Production environment variables
|
Naming convention: `env-<workflow-name>.env` — matches the GitHub Actions workflow that uses it.
|
||||||
- **Why:** Production-specific settings (live API URLs, optimized settings)
|
|
||||||
- **How:** Copied to root `.env` by GitHub Actions workflow
|
|
||||||
- **Contains:** Production API URLs, security settings, performance configs
|
|
||||||
|
|
||||||
- **`config/.env.int`** - Integration environment variables
|
- **`config/env-poweron-nyla-dev.env`** — Local development (localhost gateway)
|
||||||
- **Why:** Testing environment that mirrors production but with test data
|
- **`config/env-poweron-nyla-int.env`** — Integration (used by `poweron_nyla_int` workflow)
|
||||||
- **How:** Copied to root `.env` by integration deployment workflow
|
- **`config/env-poweron-nyla-prod.env`** — Production (used by `poweron_nyla_main` workflow)
|
||||||
- **Contains:** Staging API URLs, test user credentials, integration settings
|
|
||||||
|
Each env is copied to root `.env` at build time (by CI or manually for local dev).
|
||||||
|
|
||||||
### Usage
|
### Usage
|
||||||
```bash
|
```bash
|
||||||
# Development (loads .env.dev)
|
# Local development — copy env then start Vite
|
||||||
|
cp config/env-poweron-nyla-dev.env .env
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
# Production build (loads .env.prod)
|
# Production build (CI copies env-poweron-nyla-prod.env → .env)
|
||||||
npm run build:prod
|
npm run build:prod
|
||||||
|
|
||||||
# Integration build (loads .env.int)
|
# Integration build (CI copies env-poweron-nyla-int.env → .env)
|
||||||
npm run build:int
|
npm run build:int
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
* Configuration — reads mandatory env vars set by .env (copied from config/env-*.env by CI).
|
||||||
* Centralized access to environment variables with fallbacks
|
*
|
||||||
|
* NO silent fallbacks for critical values.
|
||||||
|
* If VITE_API_BASE_URL is missing the app fails loudly at startup.
|
||||||
|
*
|
||||||
|
* Vite replaces import.meta.env.VITE_* statically at build time.
|
||||||
|
* Dynamic access via import.meta.env[key] does NOT work in production builds.
|
||||||
|
* Therefore each variable must be accessed with its literal property name.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// API Configuration
|
const _apiBaseUrl: string = import.meta.env.VITE_API_BASE_URL;
|
||||||
export const getApiBaseUrl = (): string => {
|
|
||||||
return import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getApiTimeout = (): number => {
|
if (!_apiBaseUrl) {
|
||||||
return parseInt(import.meta.env.VITE_API_TIMEOUT || '10000');
|
throw new Error(
|
||||||
};
|
'Missing required env variable: VITE_API_BASE_URL. Ensure .env is present (cp config/env-<env>.env .env).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// App Configuration
|
export const getApiBaseUrl = (): string => _apiBaseUrl;
|
||||||
export const getAppName = (): string => {
|
|
||||||
return import.meta.env.VITE_APP_NAME || 'PowerOn';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAppVersion = (): string => {
|
export const getAppName = (): string => import.meta.env.VITE_APP_NAME || 'PowerOn';
|
||||||
return import.meta.env.VITE_APP_VERSION || '0.0.0';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAppEnvironment = (): string => {
|
|
||||||
return import.meta.env.VITE_APP_ENVIRONMENT || 'dev';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Environment Detection
|
|
||||||
export const isDevelopment = (): boolean => {
|
|
||||||
return import.meta.env.MODE === 'development' || getAppEnvironment() === 'dev';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isProduction = (): boolean => {
|
|
||||||
return import.meta.env.MODE === 'production' || getAppEnvironment() === 'prod';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isIntegration = (): boolean => {
|
|
||||||
return getAppEnvironment() === 'int';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Debug Configuration
|
|
||||||
export const isDebugMode = (): boolean => {
|
|
||||||
return import.meta.env.VITE_DEBUG === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLogLevel = (): string => {
|
|
||||||
return import.meta.env.VITE_LOG_LEVEL || 'info';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isConsoleLogsEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_CONSOLE_LOGS === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Microsoft Authentication
|
|
||||||
export const getMicrosoftClientId = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_MICROSOFT_CLIENT_ID;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getMicrosoftTenantId = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_MICROSOFT_TENANT_ID;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEntraClientSecret = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_ENTRA_CLIENT_SECRET;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEntraAuthority = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_ENTRA_AUTHORITY;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEntraRedirectPath = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_ENTRA_REDIRECT_PATH;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getEntraRedirectUri = (): string | undefined => {
|
|
||||||
return import.meta.env.VITE_ENTRA_REDIRECT_URI;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Feature Flags (if needed in the future)
|
|
||||||
export const isFeatureEnabled = (feature: string): boolean => {
|
|
||||||
const envKey = `VITE_ENABLE_${feature.toUpperCase()}`;
|
|
||||||
return import.meta.env[envKey] === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Analytics and Monitoring
|
|
||||||
export const isAnalyticsEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_ANALYTICS === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isErrorReportingEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_ERROR_REPORTING === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isPerformanceMonitoringEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_PERFORMANCE_MONITORING === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Development Server (for dev environment)
|
|
||||||
export const getDevServerPort = (): number => {
|
|
||||||
return parseInt(import.meta.env.VITE_DEV_SERVER_PORT || '5176');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDevServerHost = (): string => {
|
|
||||||
return import.meta.env.VITE_DEV_SERVER_HOST || 'localhost';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isDevServerHttps = (): boolean => {
|
|
||||||
return import.meta.env.VITE_DEV_SERVER_HTTPS === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Security Configuration
|
|
||||||
export const isHttpsEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_HTTPS === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isCspEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_CSP === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Test Configuration
|
|
||||||
export const isMockDataEnabled = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_MOCK_DATA === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isTestMode = (): boolean => {
|
|
||||||
return import.meta.env.VITE_ENABLE_TEST_MODE === 'true';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convenience object for easy destructuring
|
|
||||||
export const config = {
|
|
||||||
// API
|
|
||||||
getApiBaseUrl,
|
|
||||||
getApiTimeout,
|
|
||||||
|
|
||||||
// App
|
|
||||||
getAppName,
|
|
||||||
getAppVersion,
|
|
||||||
getAppEnvironment,
|
|
||||||
|
|
||||||
// Environment
|
|
||||||
isDevelopment,
|
|
||||||
isProduction,
|
|
||||||
isIntegration,
|
|
||||||
|
|
||||||
// Debug
|
|
||||||
isDebugMode,
|
|
||||||
getLogLevel,
|
|
||||||
isConsoleLogsEnabled,
|
|
||||||
|
|
||||||
// Microsoft Auth
|
|
||||||
getMicrosoftClientId,
|
|
||||||
getMicrosoftTenantId,
|
|
||||||
getEntraClientSecret,
|
|
||||||
getEntraAuthority,
|
|
||||||
getEntraRedirectPath,
|
|
||||||
getEntraRedirectUri,
|
|
||||||
|
|
||||||
// Features
|
|
||||||
isFeatureEnabled,
|
|
||||||
|
|
||||||
// Analytics
|
|
||||||
isAnalyticsEnabled,
|
|
||||||
isErrorReportingEnabled,
|
|
||||||
isPerformanceMonitoringEnabled,
|
|
||||||
|
|
||||||
// Dev Server
|
|
||||||
getDevServerPort,
|
|
||||||
getDevServerHost,
|
|
||||||
isDevServerHttps,
|
|
||||||
|
|
||||||
// Security
|
|
||||||
isHttpsEnabled,
|
|
||||||
isCspEnabled,
|
|
||||||
|
|
||||||
// Test
|
|
||||||
isMockDataEnabled,
|
|
||||||
isTestMode,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
6
config/env-dev.env
Normal file
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
|
// Copyright (c) 2026 PowerOn AG
|
||||||
export * from './config';
|
// All rights reserved.
|
||||||
|
export { getApiBaseUrl, getAppName } from './config';
|
||||||
// Re-export commonly used functions
|
|
||||||
export {
|
|
||||||
getApiBaseUrl,
|
|
||||||
getAppName,
|
|
||||||
isDevelopment,
|
|
||||||
isProduction,
|
|
||||||
isDebugMode,
|
|
||||||
config
|
|
||||||
} from './config';
|
|
||||||
|
|
|
||||||
17
env.d.ts
vendored
17
env.d.ts
vendored
|
|
@ -1,15 +1,8 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_API_URL: string
|
readonly VITE_API_BASE_URL?: string
|
||||||
readonly VITE_MICROSOFT_CLIENT_ID: string
|
readonly VITE_APP_NAME?: string
|
||||||
readonly VITE_MICROSOFT_TENANT_ID: string
|
}
|
||||||
readonly VITE_ENTRA_CLIENT_SECRET: string
|
|
||||||
readonly VITE_ENTRA_AUTHORITY: string
|
|
||||||
readonly VITE_ENTRA_REDIRECT_PATH: string
|
|
||||||
readonly VITE_ENTRA_REDIRECT_URI: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ImportMeta {
|
|
||||||
readonly env: ImportMetaEnv
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,12 @@ export default tseslint.config(
|
||||||
'warn',
|
'warn',
|
||||||
{ allowConstantExport: true },
|
{ 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:prod": "tsc -b && vite build --mode prod",
|
||||||
"build:int": "tsc -b && vite build --mode int",
|
"build:int": "tsc -b && vite build --mode int",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@azure/msal-browser": "^4.12.0",
|
|
||||||
"@azure/msal-react": "^3.0.12",
|
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/leaflet": "^1.9.21",
|
"@types/leaflet": "^1.9.21",
|
||||||
"@xstate/react": "^5.0.0",
|
"@xstate/react": "^5.0.0",
|
||||||
|
|
@ -47,18 +49,24 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.30.1",
|
"@eslint/js": "^9.30.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.7.2",
|
"@types/node": "^24.7.2",
|
||||||
"@types/proj4": "^2.5.6",
|
"@types/proj4": "^2.5.6",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
"@vitejs/plugin-react": "^4.6.0",
|
"@vitejs/plugin-react": "^4.6.0",
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"eslint": "^9.30.1",
|
"eslint": "^9.30.1",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.35.1",
|
"typescript-eslint": "^8.35.1",
|
||||||
"vite": "^5.4.10",
|
"vite": "^5.4.10",
|
||||||
"vite-plugin-html": "^3.2.2"
|
"vite-plugin-html": "^3.2.2",
|
||||||
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,10 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -140,7 +140,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="last-updated">
|
<div class="last-updated">
|
||||||
<strong>Last Updated:</strong> August 2025
|
<strong>Last Updated:</strong> May 2026
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-section">
|
<div class="content-section">
|
||||||
|
|
@ -272,8 +272,13 @@
|
||||||
<h2>Contact Us</h2>
|
<h2>Contact Us</h2>
|
||||||
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
|
<p>If you have any questions about this Privacy Policy or our data practices, please contact us:</p>
|
||||||
<div class="highlight-box">
|
<div class="highlight-box">
|
||||||
<p><strong>Email:</strong> privacy@poweron-ai.com</p>
|
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||||
<p><strong>Address:</strong> PowerOn AI Platform, Privacy Team</p>
|
<p><strong>Address:</strong><br>
|
||||||
|
PowerOn AG<br>
|
||||||
|
Birmensdorferstrasse 94<br>
|
||||||
|
CH-8003 Zürich<br>
|
||||||
|
Switzerland
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -283,7 +288,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -153,7 +153,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="last-updated">
|
<div class="last-updated">
|
||||||
<strong>Last Updated:</strong> August 2025
|
<strong>Last Updated:</strong> May 2026
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content-section">
|
<div class="content-section">
|
||||||
|
|
@ -315,8 +315,13 @@
|
||||||
<h2>Contact Information</h2>
|
<h2>Contact Information</h2>
|
||||||
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
<p>If you have any questions about these Terms of Service, please contact us:</p>
|
||||||
<div class="highlight-box">
|
<div class="highlight-box">
|
||||||
<p><strong>Email:</strong> legal@poweron-ai.com</p>
|
<p><strong>Email:</strong> <a href="mailto:p.motsch@poweron.swiss">p.motsch@poweron.swiss</a></p>
|
||||||
<p><strong>Address:</strong> PowerOn AI Platform, Legal Department</p>
|
<p><strong>Address:</strong><br>
|
||||||
|
PowerOn AG<br>
|
||||||
|
Birmensdorferstrasse 94<br>
|
||||||
|
CH-8003 Zürich<br>
|
||||||
|
Switzerland
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -326,7 +331,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<p>© 2025 PowerOn AI Platform. All rights reserved.</p>
|
<p>© 2026 PowerOn. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</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
|
* App.tsx
|
||||||
*
|
*
|
||||||
|
|
@ -25,7 +27,6 @@ import Reset from './pages/Reset';
|
||||||
import { InvitePage } from './pages/InvitePage';
|
import { InvitePage } from './pages/InvitePage';
|
||||||
|
|
||||||
// Providers
|
// Providers
|
||||||
import { AuthProvider } from './providers/auth/AuthProvider';
|
|
||||||
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
|
||||||
import { LanguageProvider } from './providers/language/LanguageContext';
|
import { LanguageProvider } from './providers/language/LanguageContext';
|
||||||
import { ToastProvider } from './contexts/ToastContext';
|
import { ToastProvider } from './contexts/ToastContext';
|
||||||
|
|
@ -40,11 +41,12 @@ import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
import { FeatureViewPage } from './pages/FeatureView';
|
||||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage, AdminDatabaseHealthPage } from './pages/admin';
|
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
|
||||||
|
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||||
function App() {
|
function App() {
|
||||||
// Load saved theme preference and set app name on app mount
|
// Load saved theme preference and set app name on app mount
|
||||||
|
|
@ -71,7 +73,6 @@ function App() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<AuthProvider>
|
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<VoiceCatalogProvider>
|
<VoiceCatalogProvider>
|
||||||
<WorkflowSelectionProvider>
|
<WorkflowSelectionProvider>
|
||||||
|
|
@ -125,15 +126,20 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* AUTOMATIONS DASHBOARD */}
|
{/* WORKFLOW AUTOMATION (System-Komponente) */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
<Route path="workflow-automation" element={<WorkflowAutomationPage />} />
|
||||||
|
|
||||||
|
{/* ============================================== */}
|
||||||
|
{/* RAG INVENTORY */}
|
||||||
|
{/* ============================================== */}
|
||||||
|
<Route path="rag-inventory" element={<RagInventoryPage />} />
|
||||||
|
|
||||||
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
{/* Legacy top-level routes – redirect to dashboard (migrated to feature-instance routes) */}
|
||||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
|
||||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
<Route path="speech" element={<Navigate to="/" replace />} />
|
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||||
|
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* FEATURE-INSTANZ ROUTES */}
|
{/* FEATURE-INSTANZ ROUTES */}
|
||||||
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
||||||
|
|
@ -147,8 +153,7 @@ function App() {
|
||||||
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
|
||||||
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
|
||||||
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
|
||||||
<Route path="documents" element={<FeatureViewPage view="documents" />} />
|
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
|
||||||
<Route path="positions" element={<FeatureViewPage view="positions" />} />
|
|
||||||
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
<Route path="roles" element={<FeatureViewPage view="roles" />} />
|
||||||
<Route path="access" element={<FeatureViewPage view="access" />} />
|
<Route path="access" element={<FeatureViewPage view="access" />} />
|
||||||
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
<Route path="runs" element={<FeatureViewPage view="runs" />} />
|
||||||
|
|
@ -158,8 +163,7 @@ function App() {
|
||||||
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
<Route path="chat" element={<FeatureViewPage view="chat" />} />
|
||||||
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
<Route path="threads" element={<FeatureViewPage view="threads" />} />
|
||||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
|
||||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||||
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||||
|
|
@ -169,24 +173,26 @@ function App() {
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
{/* Workspace + Automation2 Editor */}
|
{/* Workspace Editor */}
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
|
||||||
|
|
||||||
{/* Automation2 Workflows & Tasks */}
|
|
||||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
|
||||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
||||||
|
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
|
||||||
|
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
|
||||||
|
<Route path="modules" element={<FeatureViewPage view="modules" />} />
|
||||||
|
|
||||||
{/* Neutralization Feature Views */}
|
{/* Neutralization Feature Views */}
|
||||||
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
<Route path="playground" element={<FeatureViewPage view="playground" />} />
|
||||||
|
|
||||||
{/* CommCoach Feature Views */}
|
{/* CommCoach Feature Views */}
|
||||||
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
|
<Route path="session" element={<FeatureViewPage view="session" />} />
|
||||||
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
|
|
||||||
|
{/* Redmine Feature Views */}
|
||||||
|
<Route path="stats" element={<FeatureViewPage view="stats" />} />
|
||||||
|
<Route path="browser" element={<FeatureViewPage view="browser" />} />
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||||
|
|
@ -215,7 +221,7 @@ function App() {
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="languages" element={null} />
|
<Route path="languages" element={null} />
|
||||||
<Route path="database-health" element={<AdminDatabaseHealthPage />} />
|
<Route path="database-health" element={null} />
|
||||||
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
<Route path="demo-config" element={<AdminDemoConfigPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
|
|
@ -235,7 +241,6 @@ function App() {
|
||||||
</WorkflowSelectionProvider>
|
</WorkflowSelectionProvider>
|
||||||
</VoiceCatalogProvider>
|
</VoiceCatalogProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</AuthProvider>
|
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
src/api.ts
75
src/api.ts
|
|
@ -1,25 +1,10 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
// api.ts
|
// api.ts
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
|
||||||
import { clearUserDataCache, getUserDataCache } from './utils/userCache';
|
import { clearUserDataCache, getUserDataCache } from './utils/userCache';
|
||||||
|
|
||||||
// Utility function to resolve hostname to IP address
|
|
||||||
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
|
|
||||||
try {
|
|
||||||
// For localhost, return as is
|
|
||||||
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
||||||
return hostname;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For production domains, we can't directly resolve IP due to CORS
|
|
||||||
// But we can show the hostname which is more useful anyway
|
|
||||||
return hostname;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not resolve hostname to IP:', error);
|
|
||||||
return hostname;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract mandate/instance context from current URL.
|
* Extract mandate/instance context from current URL.
|
||||||
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
|
* URL pattern: /mandates/:mandateId/:featureCode/:instanceId/...
|
||||||
|
|
@ -44,45 +29,25 @@ const getContextFromUrl = (): { mandateId?: string; instanceId?: string } => {
|
||||||
|
|
||||||
import { getApiBaseUrl } from '../config/config';
|
import { getApiBaseUrl } from '../config/config';
|
||||||
|
|
||||||
|
const _baseUrl = getApiBaseUrl();
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`[api] Backend: ${_baseUrl} | env: ${import.meta.env.MODE}`);
|
||||||
|
}
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: getApiBaseUrl(),
|
baseURL: _baseUrl,
|
||||||
withCredentials: true
|
withCredentials: true,
|
||||||
|
paramsSerializer: { indexes: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add a request interceptor to add the auth token, context headers, and log backend IP
|
// Add a request interceptor to add the auth token, context headers
|
||||||
api.interceptors.request.use(
|
api.interceptors.request.use(
|
||||||
async (config) => {
|
async (config) => {
|
||||||
// Log backend information
|
// Add auth token if available (otherwise httpOnly cookies are used automatically)
|
||||||
const backendUrl = config.baseURL || getApiBaseUrl();
|
|
||||||
console.log(`🌐 Communicating with backend: ${backendUrl}`);
|
|
||||||
|
|
||||||
// Try to resolve and log the IP address
|
|
||||||
if (backendUrl) {
|
|
||||||
try {
|
|
||||||
const url = new URL(backendUrl);
|
|
||||||
const hostname = url.hostname;
|
|
||||||
const resolvedIP = await resolveHostnameToIP(hostname);
|
|
||||||
|
|
||||||
console.log(`📍 Backend hostname: ${hostname}`);
|
|
||||||
console.log(`🔗 Full backend URL: ${backendUrl}`);
|
|
||||||
console.log(`🌍 Resolved address: ${resolvedIP}`);
|
|
||||||
|
|
||||||
// Log environment info
|
|
||||||
console.log(`🏗️ Environment: ${import.meta.env.MODE}`);
|
|
||||||
console.log(`⚙️ API Base URL: ${getApiBaseUrl()}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Could not parse backend URL:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for auth token in localStorage and add to headers
|
|
||||||
const authToken = localStorage.getItem('authToken');
|
const authToken = localStorage.getItem('authToken');
|
||||||
if (authToken && config.headers) {
|
if (authToken && config.headers) {
|
||||||
config.headers.Authorization = `Bearer ${authToken}`;
|
config.headers.Authorization = `Bearer ${authToken}`;
|
||||||
console.log('🔑 Using Bearer token for authentication');
|
|
||||||
} else {
|
|
||||||
// Fallback: httpOnly cookies
|
|
||||||
console.log('🍪 Using httpOnly cookies for authentication (automatic)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send app language to backend so i18n labels match the UI
|
// Send app language to backend so i18n labels match the UI
|
||||||
|
|
@ -92,6 +57,20 @@ api.interceptors.request.use(
|
||||||
config.headers['Accept-Language'] = appLanguage;
|
config.headers['Accept-Language'] = appLanguage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
|
||||||
|
// resolve "now" for AI agents and user-visible time strings without
|
||||||
|
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
|
||||||
|
if (config.headers) {
|
||||||
|
try {
|
||||||
|
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
|
if (browserTimezone) {
|
||||||
|
config.headers['X-User-Timezone'] = browserTimezone;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add multi-tenant context headers from URL (if not already set)
|
// Add multi-tenant context headers from URL (if not already set)
|
||||||
// This ensures Feature-Instance roles are loaded for permission checks
|
// This ensures Feature-Instance roles are loaded for permission checks
|
||||||
const context = getContextFromUrl();
|
const context = getContextFromUrl();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
import type { AttributeType } from '../utils/attributeTypeMapper';
|
||||||
|
|
||||||
|
export type { AttributeType };
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
|
|
@ -7,7 +12,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
export interface AttributeDefinition {
|
export interface AttributeDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
|
type: AttributeType;
|
||||||
sortable?: boolean;
|
sortable?: boolean;
|
||||||
filterable?: boolean;
|
filterable?: boolean;
|
||||||
searchable?: boolean;
|
searchable?: boolean;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
|
import { addCSRFTokenToHeaders } from '../utils/csrfUtils';
|
||||||
|
|
@ -12,14 +14,30 @@ export interface LoginRequest {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
type: 'local_auth_success';
|
type: 'local_auth_success' | 'mfa_required' | 'mfa_setup_required';
|
||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
tokenType?: string;
|
tokenType?: string;
|
||||||
authenticationAuthority?: string;
|
authenticationAuthority?: string;
|
||||||
|
mfaToken?: string;
|
||||||
|
provisioningUri?: string;
|
||||||
label?: any;
|
label?: any;
|
||||||
fieldLabels?: any;
|
fieldLabels?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MfaVerifyRequest {
|
||||||
|
token: string;
|
||||||
|
code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaSetupResponse {
|
||||||
|
provisioningUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MfaStatusResponse {
|
||||||
|
mfaEnabled: boolean;
|
||||||
|
mfaRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegisterData {
|
export interface RegisterData {
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
|
@ -316,3 +334,36 @@ export async function logoutApi(): Promise<void> {
|
||||||
await api.post('/api/local/logout');
|
await api.post('/api/local/logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MFA API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function mfaVerifyApi(data: MfaVerifyRequest): Promise<LoginResponse> {
|
||||||
|
const response = await api.post<LoginResponse>('/api/mfa/verify', data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mfaSetupApi(): Promise<MfaSetupResponse> {
|
||||||
|
const response = await api.post<MfaSetupResponse>('/api/mfa/setup');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mfaConfirmApi(code: string, token?: string): Promise<{ mfaEnabled: boolean }> {
|
||||||
|
if (token) {
|
||||||
|
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm', { code, token });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/confirm-authenticated', { code });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mfaStatusApi(): Promise<MfaStatusResponse> {
|
||||||
|
const response = await api.get<MfaStatusResponse>('/api/mfa/status');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mfaDisableApi(code: string): Promise<{ mfaEnabled: boolean }> {
|
||||||
|
const response = await api.post<{ mfaEnabled: boolean }>('/api/mfa/disable', { code });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -29,13 +31,36 @@ export interface BillingTransaction {
|
||||||
aicoreProvider?: string;
|
aicoreProvider?: string;
|
||||||
aicoreModel?: string;
|
aicoreModel?: string;
|
||||||
createdByUserId?: string;
|
createdByUserId?: string;
|
||||||
createdAt?: string;
|
sysCreatedAt?: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
mandateName?: string;
|
mandateName?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
userName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pagination request for GET /api/billing/transactions with `pagination` JSON (table + grouping). */
|
||||||
|
export interface BillingTransactionsPaginationParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
|
filters?: Record<string, any>;
|
||||||
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillingTransactionsPaginatedResponse {
|
||||||
|
items: BillingTransaction[];
|
||||||
|
pagination?: {
|
||||||
|
currentPage: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalItems: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
|
}
|
||||||
|
|
||||||
export interface BillingSettings {
|
export interface BillingSettings {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -56,8 +81,12 @@ export interface BillingSettingsUpdate {
|
||||||
rechargeMaxPerMonth?: number;
|
rechargeMaxPerMonth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BillingBucketSize = 'day' | 'month' | 'year';
|
||||||
|
|
||||||
export interface UsageReport {
|
export interface UsageReport {
|
||||||
period: string;
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
bucketSize: BillingBucketSize;
|
||||||
totalCost: number;
|
totalCost: number;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
costByProvider: Record<string, number>;
|
costByProvider: Record<string, number>;
|
||||||
|
|
@ -65,6 +94,12 @@ export interface UsageReport {
|
||||||
costByFeature: Record<string, number>;
|
costByFeature: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatisticsRangeRequest {
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
bucketSize: BillingBucketSize;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountSummary {
|
export interface AccountSummary {
|
||||||
id: string;
|
id: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
|
@ -125,7 +160,31 @@ export async function fetchBalanceForMandate(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch transaction history
|
* Fetch transaction history (table UI: pagination, filters, sort, saved views, grouping).
|
||||||
|
* Endpoint: GET /api/billing/transactions?pagination=...
|
||||||
|
*/
|
||||||
|
export async function fetchTransactionsPaginated(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params?: BillingTransactionsPaginationParams
|
||||||
|
): Promise<BillingTransactionsPaginatedResponse> {
|
||||||
|
const paginationObj: Record<string, unknown> = {};
|
||||||
|
if (params?.page !== undefined) paginationObj.page = params.page;
|
||||||
|
if (params?.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
|
||||||
|
if (params?.sort?.length) paginationObj.sort = params.sort;
|
||||||
|
if (params?.filters && Object.keys(params.filters).length > 0) paginationObj.filters = params.filters;
|
||||||
|
if (params?.search) paginationObj.search = params.search;
|
||||||
|
if (params?.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params?.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
|
return await request({
|
||||||
|
url: '/api/billing/transactions',
|
||||||
|
method: 'get',
|
||||||
|
params: { pagination: JSON.stringify(paginationObj) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch transaction history (legacy array window)
|
||||||
* Endpoint: GET /api/billing/transactions
|
* Endpoint: GET /api/billing/transactions
|
||||||
*/
|
*/
|
||||||
export async function fetchTransactions(
|
export async function fetchTransactions(
|
||||||
|
|
@ -141,24 +200,21 @@ export async function fetchTransactions(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch usage statistics
|
* Fetch usage statistics for an explicit date range.
|
||||||
* Endpoint: GET /api/billing/statistics/{period}
|
* Endpoint: GET /api/billing/statistics
|
||||||
*/
|
*/
|
||||||
export async function fetchStatistics(
|
export async function fetchStatistics(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
period: 'day' | 'month' | 'year',
|
range: StatisticsRangeRequest
|
||||||
year: number,
|
|
||||||
month?: number
|
|
||||||
): Promise<UsageReport> {
|
): Promise<UsageReport> {
|
||||||
const params: Record<string, any> = { year };
|
|
||||||
if (month !== undefined) {
|
|
||||||
params.month = month;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/billing/statistics/${period}`,
|
url: '/api/billing/statistics',
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params
|
params: {
|
||||||
|
dateFrom: range.dateFrom,
|
||||||
|
dateTo: range.dateTo,
|
||||||
|
bucketSize: range.bucketSize,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -225,6 +281,19 @@ export async function addCreditAdmin(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the server-side allow-list of CHF top-up amounts
|
||||||
|
* Endpoint: GET /api/billing/checkout/amounts
|
||||||
|
*/
|
||||||
|
export async function fetchCheckoutAmounts(
|
||||||
|
request: ApiRequestFunction
|
||||||
|
): Promise<number[]> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/billing/checkout/amounts',
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create Stripe Checkout Session for credit top-up
|
* Create Stripe Checkout Session for credit top-up
|
||||||
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
* Endpoint: POST /api/billing/checkout/create/{mandateId}
|
||||||
|
|
|
||||||
|
|
@ -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 api from '../api';
|
||||||
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
@ -109,8 +111,8 @@ export interface CoachingUserProfile {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DashboardData {
|
export interface DashboardData {
|
||||||
totalContexts: number;
|
totalModules: number;
|
||||||
activeContexts: number;
|
activeModules: number;
|
||||||
totalSessions: number;
|
totalSessions: number;
|
||||||
totalMinutes: number;
|
totalMinutes: number;
|
||||||
streakDays: number;
|
streakDays: number;
|
||||||
|
|
@ -122,7 +124,11 @@ export interface DashboardData {
|
||||||
goalProgress?: number;
|
goalProgress?: number;
|
||||||
badges?: CoachingBadge[];
|
badges?: CoachingBadge[];
|
||||||
level?: { number: number; label: string; totalSessions: number };
|
level?: { number: number; label: string; totalSessions: number };
|
||||||
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
modules: Array<{ id: string; title: string; moduleType: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||||
|
/** @deprecated Use totalModules/activeModules/modules instead */
|
||||||
|
totalContexts?: number;
|
||||||
|
activeContexts?: number;
|
||||||
|
contexts?: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface SSEEvent {
|
||||||
|
|
@ -133,31 +139,73 @@ export interface SSEEvent {
|
||||||
|
|
||||||
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
|
||||||
|
|
||||||
|
export function getApiRequest(): ApiRequestFunction {
|
||||||
|
return async (options: ApiRequestOptions<any>) => {
|
||||||
|
const response = await api(options);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Context API
|
// Module API (TrainingModule — replaces Context API)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function listModulesApi(request: ApiRequestFunction, instanceId: string): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||||
|
return data.modules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModuleApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
title: string; moduleType?: string; goals?: string; personaId?: string; kpiTargets?: string;
|
||||||
|
}): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||||
|
return data.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModuleDetailApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'get' });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string, body: any): Promise<any> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'put', data: body });
|
||||||
|
return data.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModuleApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<void> {
|
||||||
|
await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}`, method: 'delete' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSessionsApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<any[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/sessions`, method: 'get' });
|
||||||
|
return data.sessions || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Context / Module API (uses /modules/ endpoints)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
export async function getContextsApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingContext[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'get' });
|
||||||
return data.contexts || [];
|
return data.modules || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
export async function createContextApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
title: string; description?: string; category?: string; goals?: string[];
|
title: string; description?: string; category?: string; goals?: string[];
|
||||||
}): Promise<CoachingContext> {
|
}): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts`, method: 'post', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules`, method: 'post', data: body });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
export async function getContextDetailApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
context: CoachingContext; tasks: CoachingTask[]; scores: CoachingScore[]; sessions: CoachingSession[];
|
||||||
}> {
|
}> {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/commcoach/${instanceId}/contexts/${contextId}`,
|
url: `/api/commcoach/${instanceId}/modules/${contextId}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
params: { _t: Date.now() },
|
params: { _t: Date.now() },
|
||||||
});
|
});
|
||||||
const ctx = data?.context ?? data;
|
const ctx = data?.module ?? data;
|
||||||
return {
|
return {
|
||||||
context: ctx,
|
context: ctx,
|
||||||
tasks: data?.tasks ?? [],
|
tasks: data?.tasks ?? [],
|
||||||
|
|
@ -167,22 +215,22 @@ export async function getContextDetailApi(request: ApiRequestFunction, instanceI
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
export async function updateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: any): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'put', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'put', data: body });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
export async function deleteContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<void> {
|
||||||
await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
export async function archiveContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/archive`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/archive`, method: 'post' });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
export async function activateContextApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingContext> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/activate`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/activate`, method: 'post' });
|
||||||
return data.context;
|
return data.module;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -192,7 +240,7 @@ export async function activateContextApi(request: ApiRequestFunction, instanceId
|
||||||
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
export async function startSessionApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<{
|
||||||
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
session: CoachingSession; messages: CoachingMessage[]; resumed: boolean;
|
||||||
}> {
|
}> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`, method: 'post' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/sessions/start`, method: 'post' });
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +255,7 @@ export async function startSessionStreamApi(
|
||||||
try {
|
try {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
|
||||||
const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
|
const url = `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/sessions/start${personaParam}`;
|
||||||
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
const authToken = localStorage.getItem('authToken');
|
const authToken = localStorage.getItem('authToken');
|
||||||
|
|
@ -243,14 +291,11 @@ export async function startSessionStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) {
|
let event: SSEEvent;
|
||||||
const event: SSEEvent = JSON.parse(jsonStr);
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip malformed lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -348,14 +393,11 @@ export async function sendMessageStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) {
|
let event: SSEEvent;
|
||||||
const event: SSEEvent = JSON.parse(jsonStr);
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
onEvent(event);
|
onEvent(event);
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// skip malformed lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -424,10 +466,12 @@ export async function sendAudioStreamApi(
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('data: ')) {
|
if (line.startsWith('data: ')) {
|
||||||
try {
|
const jsonStr = line.slice(6);
|
||||||
const jsonStr = line.slice(6);
|
if (jsonStr.trim()) {
|
||||||
if (jsonStr.trim()) onEvent(JSON.parse(jsonStr));
|
let event: SSEEvent;
|
||||||
} catch { /* skip */ }
|
try { event = JSON.parse(jsonStr); } catch { continue; }
|
||||||
|
onEvent(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -446,14 +490,14 @@ export async function sendAudioStreamApi(
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
export async function getTasksApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<CoachingTask[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'get' });
|
||||||
return data.tasks || [];
|
return data.tasks || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
export async function createTaskApi(request: ApiRequestFunction, instanceId: string, contextId: string, body: {
|
||||||
title: string; description?: string; priority?: string; dueDate?: string;
|
title: string; description?: string; priority?: string; dueDate?: string;
|
||||||
}): Promise<CoachingTask> {
|
}): Promise<CoachingTask> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/tasks`, method: 'post', data: body });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/tasks`, method: 'post', data: body });
|
||||||
return data.task;
|
return data.task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -500,7 +544,14 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId:
|
||||||
|
|
||||||
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise<CoachingPersona[]> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
|
||||||
return data.personas || [];
|
return data.items || data.personas || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPersonasPaginated(request: ApiRequestFunction, instanceId: string, params?: any): Promise<any> {
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
if (params) queryParams.pagination = JSON.stringify(params);
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get', params: queryParams });
|
||||||
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
|
||||||
|
|
@ -510,10 +561,31 @@ export async function createPersonaApi(request: ApiRequestFunction, instanceId:
|
||||||
return data.persona;
|
return data.persona;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updatePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string, body: {
|
||||||
|
label?: string; description?: string; gender?: string; systemPromptOverride?: string; isActive?: boolean;
|
||||||
|
}): Promise<CoachingPersona> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'put', data: body });
|
||||||
|
return data.persona;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise<void> {
|
||||||
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Module-Persona Mapping API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string): Promise<string[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'get' });
|
||||||
|
return data.personaIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setModulePersonasApi(request: ApiRequestFunction, instanceId: string, moduleId: string, personaIds: string[]): Promise<string[]> {
|
||||||
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${moduleId}/personas`, method: 'put', data: { personaIds } });
|
||||||
|
return data.personaIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Badge API (Iteration 2)
|
// Badge API (Iteration 2)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -529,7 +601,7 @@ export async function getBadgesApi(request: ApiRequestFunction, instanceId: stri
|
||||||
|
|
||||||
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`;
|
return `${baseURL}/api/commcoach/${instanceId}/modules/${contextId}/export?format=${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
|
||||||
|
|
@ -544,6 +616,6 @@ export function getSessionExportUrl(instanceId: string, sessionId: string, forma
|
||||||
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise<Record<string, Array<{
|
||||||
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
score: number; trend: string; evidence?: string; createdAt?: string; sessionId?: string;
|
||||||
}>>> {
|
}>>> {
|
||||||
const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
|
const data = await request({ url: `/api/commcoach/${instanceId}/modules/${contextId}/scores/history`, method: 'get' });
|
||||||
return data.history || {};
|
return data.history || {};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,25 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES & INTERFACES
|
// TYPES & INTERFACES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface KnowledgePreferences {
|
||||||
|
schemaVersion?: number;
|
||||||
|
mailContentDepth?: 'metadata' | 'snippet' | 'full';
|
||||||
|
mailIndexAttachments?: boolean;
|
||||||
|
filesIndexBinaries?: boolean;
|
||||||
|
clickupScope?: 'titles' | 'title_description' | 'with_comments';
|
||||||
|
clickupIndexAttachments?: boolean;
|
||||||
|
maxAgeDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Connection {
|
export interface Connection {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
authority: 'local' | 'google' | 'msft' | 'clickup';
|
authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
|
||||||
externalId: string;
|
externalId: string;
|
||||||
externalUsername: string;
|
externalUsername: string;
|
||||||
externalEmail?: string;
|
externalEmail?: string;
|
||||||
|
|
@ -15,6 +27,8 @@ export interface Connection {
|
||||||
connectedAt: number; // Backend uses float for UTC timestamp in seconds
|
connectedAt: number; // Backend uses float for UTC timestamp in seconds
|
||||||
lastChecked: number; // Backend uses float for UTC timestamp in seconds
|
lastChecked: number; // Backend uses float for UTC timestamp in seconds
|
||||||
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
|
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
|
||||||
|
knowledgeIngestionEnabled?: boolean;
|
||||||
|
knowledgePreferences?: KnowledgePreferences | null;
|
||||||
[key: string]: any; // Allow additional properties
|
[key: string]: any; // Allow additional properties
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,6 +51,22 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
|
||||||
|
viewKey?: string;
|
||||||
|
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupBand {
|
||||||
|
path: string[];
|
||||||
|
label: string;
|
||||||
|
startRowIndex: number;
|
||||||
|
rowCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroupLayout {
|
||||||
|
levels: string[];
|
||||||
|
bands: GroupBand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -47,17 +77,21 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateConnectionData {
|
export interface CreateConnectionData {
|
||||||
id?: string;
|
id?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
authority?: 'msft' | 'google' | 'clickup';
|
authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
|
||||||
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
|
type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
|
||||||
externalId?: string;
|
externalId?: string;
|
||||||
externalUsername?: string;
|
externalUsername?: string;
|
||||||
externalEmail?: string;
|
externalEmail?: string;
|
||||||
status?: 'active' | 'expired' | 'revoked' | 'pending';
|
status?: 'active' | 'expired' | 'revoked' | 'pending';
|
||||||
|
knowledgeIngestionEnabled?: boolean;
|
||||||
|
knowledgePreferences?: KnowledgePreferences | null;
|
||||||
connectedAt?: number;
|
connectedAt?: number;
|
||||||
lastChecked?: number;
|
lastChecked?: number;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
|
|
@ -103,6 +137,8 @@ export async function fetchConnections(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
@ -136,14 +172,20 @@ export async function createConnection(
|
||||||
/**
|
/**
|
||||||
* Connect to a service (initiate OAuth)
|
* Connect to a service (initiate OAuth)
|
||||||
* Endpoint: POST /api/connections/{connectionId}/connect
|
* Endpoint: POST /api/connections/{connectionId}/connect
|
||||||
|
*
|
||||||
|
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
|
||||||
|
* Required when newly added scopes (e.g. Calendar/Contacts after a
|
||||||
|
* feature rollout) need to be granted on top of the existing token.
|
||||||
*/
|
*/
|
||||||
export async function connectService(
|
export async function connectService(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
connectionId: string
|
connectionId: string,
|
||||||
|
reauth: boolean = false
|
||||||
): Promise<ConnectResponse> {
|
): Promise<ConnectResponse> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/connections/${connectionId}/connect`,
|
url: `/api/connections/${connectionId}/connect`,
|
||||||
method: 'post'
|
method: 'post',
|
||||||
|
data: reauth ? { reauth: true } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,3 +263,235 @@ export async function refreshGoogleToken(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
|
||||||
|
* UserConnection. The backend validates the token via /1/profile and stores it
|
||||||
|
* as the connection's data-access bearer token.
|
||||||
|
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
|
||||||
|
*/
|
||||||
|
export async function submitInfomaniakToken(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
connectionId: string,
|
||||||
|
token: string
|
||||||
|
): Promise<{
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
type: string;
|
||||||
|
externalUsername: string;
|
||||||
|
externalEmail?: string | null;
|
||||||
|
lastChecked: number;
|
||||||
|
}> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/infomaniak/connections/${connectionId}/token`,
|
||||||
|
method: 'post',
|
||||||
|
data: { token }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RAG KNOWLEDGE CONSENT & CONTROL
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function patchKnowledgeConsent(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
connectionId: string,
|
||||||
|
enabled: boolean
|
||||||
|
): Promise<{ connectionId: string; knowledgeIngestionEnabled: boolean; purged?: any; cancelledJobs?: number; bootstrapEnqueued?: boolean }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/connections/${connectionId}/knowledge-consent`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { enabled }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchKnowledgePreferences(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
connectionId: string,
|
||||||
|
preferences: KnowledgePreferences
|
||||||
|
): Promise<{ connectionId: string; knowledgePreferences: KnowledgePreferences; updated: boolean }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/connections/${connectionId}/knowledge-preferences`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { preferences }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postKnowledgeStop(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
connectionId: string
|
||||||
|
): Promise<{ connectionId: string; cancelled: number }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/connections/${connectionId}/knowledge-stop`,
|
||||||
|
method: 'post'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagLimits {
|
||||||
|
maxItems?: number;
|
||||||
|
maxBytes?: number;
|
||||||
|
maxFileSize?: number;
|
||||||
|
maxDepth?: number;
|
||||||
|
// ClickUp variant
|
||||||
|
maxTasks?: number;
|
||||||
|
maxWorkspaces?: number;
|
||||||
|
maxListsPerWorkspace?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceSettings {
|
||||||
|
ragLimits?: RagLimits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostEstimate {
|
||||||
|
estimatedTokens: number;
|
||||||
|
estimatedChf: number;
|
||||||
|
basis: {
|
||||||
|
kind: string;
|
||||||
|
limits: Record<string, number>;
|
||||||
|
assumptions: Record<string, any>;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
sourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchDataSourceSettings(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string,
|
||||||
|
settings: DataSourceSettings
|
||||||
|
): Promise<{ sourceId: string; settings: DataSourceSettings; updated: boolean }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/settings`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { settings }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataSourceCostEstimate(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
dataSourceId: string
|
||||||
|
): Promise<CostEstimate> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/datasources/${dataSourceId}/cost-estimate`,
|
||||||
|
method: 'get'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag toggles (neutralize / scope / ragIndexEnabled) now go through the
|
||||||
|
// generic UDB endpoint POST /api/udb/node/{key}/flag/{flag}; see
|
||||||
|
// `UdbSourcesProvider` and the wiki UDB reference page.
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RAG INVENTORY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface RagDataSourceDto {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
sourceType: string;
|
||||||
|
/** Three-state inherit semantics on backend; UI reads as effective boolean from RAG inventory aggregator. */
|
||||||
|
ragIndexEnabled: boolean | null;
|
||||||
|
neutralize: boolean | null;
|
||||||
|
lastIndexed: number | null;
|
||||||
|
/** Distinct files indexed for this DataSource (one row per source document). */
|
||||||
|
fileCount: number;
|
||||||
|
/** Embedding-sized text fragments (one per ContentChunk row, ~400 tokens each). */
|
||||||
|
chunkCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagConnectionDto {
|
||||||
|
id: string;
|
||||||
|
authority: string;
|
||||||
|
externalEmail: string;
|
||||||
|
knowledgeIngestionEnabled: boolean;
|
||||||
|
preferences: KnowledgePreferences;
|
||||||
|
dataSources: RagDataSourceDto[];
|
||||||
|
totalFiles: number;
|
||||||
|
totalChunks: number;
|
||||||
|
runningJobs: {
|
||||||
|
jobId: string;
|
||||||
|
progress: number;
|
||||||
|
/** Already translated server-side. */
|
||||||
|
progressMessage: string;
|
||||||
|
}[];
|
||||||
|
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||||
|
lastSuccess?: {
|
||||||
|
jobId: string;
|
||||||
|
finishedAt: number | null;
|
||||||
|
indexed: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
skippedPolicy: number;
|
||||||
|
failed: number;
|
||||||
|
durationMs: number;
|
||||||
|
/** Name of the first budget that bit (e.g. "maxBytes", "maxItems", "maxTasks"); null if walk completed naturally. */
|
||||||
|
stoppedAtLimit?: string | null;
|
||||||
|
/** Effective limits used by the walker, for showing the value next to the limit name. */
|
||||||
|
limits?: Record<string, number>;
|
||||||
|
bytesProcessed?: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagFeatureDataSourceDto {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
tableName: string;
|
||||||
|
featureCode: string;
|
||||||
|
ragIndexEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagFeatureInstanceDto {
|
||||||
|
featureInstanceId: string;
|
||||||
|
featureCode: string;
|
||||||
|
label: string;
|
||||||
|
mandateId: string;
|
||||||
|
fileCount: number;
|
||||||
|
chunkCount: number;
|
||||||
|
statusCounts: Record<string, number>;
|
||||||
|
dataSources: RagFeatureDataSourceDto[];
|
||||||
|
ragEnabled: boolean;
|
||||||
|
runningJobs?: {
|
||||||
|
jobId: string;
|
||||||
|
progress: number;
|
||||||
|
progressMessage: string;
|
||||||
|
}[];
|
||||||
|
lastError?: { jobId: string; errorMessage: string; finishedAt: number | null } | null;
|
||||||
|
lastSuccess?: {
|
||||||
|
jobId: string;
|
||||||
|
finishedAt: number | null;
|
||||||
|
indexed: number;
|
||||||
|
skippedDuplicate: number;
|
||||||
|
failed: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagInventoryDto {
|
||||||
|
connections: RagConnectionDto[];
|
||||||
|
featureInstances?: RagFeatureInstanceDto[];
|
||||||
|
totals: { files: number; chunks: number; bytes?: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RagActiveJobDto {
|
||||||
|
jobId: string;
|
||||||
|
connectionId: string;
|
||||||
|
connectionLabel?: string;
|
||||||
|
jobType: string;
|
||||||
|
progress: number | null;
|
||||||
|
/** Already translated server-side. */
|
||||||
|
progressMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRagInventoryMe(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||||
|
return await request({ url: '/api/rag/inventory/me', method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRagInventoryMandate(request: ApiRequestFunction): Promise<RagInventoryDto> {
|
||||||
|
return await request({ url: '/api/rag/inventory/mandate', method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRagInventoryPlatform(request: ApiRequestFunction): Promise<any> {
|
||||||
|
return await request({ url: '/api/rag/inventory/platform', method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRagActiveJobs(request: ApiRequestFunction): Promise<RagActiveJobDto[]> {
|
||||||
|
return await request({ url: '/api/rag/inventory/jobs', method: 'get' });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Features API
|
* Features API
|
||||||
*
|
*
|
||||||
|
|
@ -14,8 +16,6 @@ import type {
|
||||||
InstancePermissions,
|
InstancePermissions,
|
||||||
AccessLevel,
|
AccessLevel,
|
||||||
} from '../types/mandate';
|
} from '../types/mandate';
|
||||||
import { mandateDisplayLabel } from '../utils/mandateDisplayUtils';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA (Temporär bis Backend bereit)
|
// MOCK DATA (Temporär bis Backend bereit)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -172,56 +172,11 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('📡 featuresApi: Fetching /api/features/my');
|
|
||||||
const response = await api.get<FeaturesMyResponse>('/api/features/my');
|
const response = await api.get<FeaturesMyResponse>('/api/features/my');
|
||||||
|
|
||||||
// Get the actual data (response.data contains the FeaturesMyResponse)
|
// Get the actual data (response.data contains the FeaturesMyResponse)
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// DEBUG: Log all chatbot instances and their permissions
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Full response received', {
|
|
||||||
response,
|
|
||||||
data,
|
|
||||||
hasMandates: !!data?.mandates,
|
|
||||||
mandateCount: data?.mandates?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.mandates) {
|
|
||||||
data.mandates.forEach(mandate => {
|
|
||||||
mandate.features.forEach(feature => {
|
|
||||||
if (feature.code === 'chatbot') {
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', {
|
|
||||||
mandateId: mandate.id,
|
|
||||||
mandateName: mandateDisplayLabel(mandate),
|
|
||||||
featureCode: feature.code,
|
|
||||||
instanceCount: feature.instances.length,
|
|
||||||
});
|
|
||||||
feature.instances.forEach(instance => {
|
|
||||||
console.log('🔍 [DEBUG] featuresApi: Chatbot Instance Details:', {
|
|
||||||
instanceId: instance.id,
|
|
||||||
instanceLabel: instance.instanceLabel,
|
|
||||||
featureCode: instance.featureCode,
|
|
||||||
userRoles: instance.userRoles,
|
|
||||||
permissions: instance.permissions,
|
|
||||||
views: instance.permissions?.views,
|
|
||||||
viewKeys: instance.permissions?.views ? Object.keys(instance.permissions.views) : [],
|
|
||||||
hasConversationsView: instance.permissions?.views?.['chatbot-conversations'] ||
|
|
||||||
instance.permissions?.views?.['ui.feature.chatbot.conversations'] ||
|
|
||||||
instance.permissions?.views?.['_all'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ featuresApi: Loaded features:', {
|
|
||||||
mandateCount: data?.mandates?.length || 0,
|
|
||||||
totalInstances: data?.mandates
|
|
||||||
?.flatMap(m => m.features)
|
|
||||||
?.flatMap(f => f.instances)
|
|
||||||
?.length || 0,
|
|
||||||
});
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ featuresApi: Error fetching features:', error);
|
console.error('❌ featuresApi: Error fetching features:', error);
|
||||||
|
|
@ -239,7 +194,6 @@ export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
|
||||||
return [
|
return [
|
||||||
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
{ code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
|
||||||
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
{ code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
|
||||||
{ code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -34,6 +36,9 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
|
owner?: 'all' | 'me' | 'shared';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -44,6 +49,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for the request function passed to API functions
|
// Type for the request function passed to API functions
|
||||||
|
|
@ -103,6 +110,9 @@ export async function fetchFiles(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
if (params.owner) requestParams.owner = params.owner;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
@ -186,110 +196,72 @@ export async function deleteFiles(
|
||||||
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
return uniqueIds.map(fileId => ({ success: true, fileId }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolders(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
folderIds: string[],
|
|
||||||
recursiveFolders: boolean = true
|
|
||||||
): Promise<{ deletedFiles: number; deletedFolders: number }> {
|
|
||||||
const uniqueIds = [...new Set(folderIds.filter(Boolean))];
|
|
||||||
if (uniqueIds.length === 0) return { deletedFiles: 0, deletedFolders: 0 };
|
|
||||||
return await request({
|
|
||||||
url: '/api/files/batch-delete',
|
|
||||||
method: 'post',
|
|
||||||
data: { folderIds: uniqueIds, recursiveFolders }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FOLDER API FUNCTIONS
|
// GROUP BULK API FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export interface FolderInfo {
|
/** Patch scope for all files in a group (recursive) */
|
||||||
id: string;
|
export async function patchGroupScope(
|
||||||
name: string;
|
|
||||||
parentId: string | null;
|
|
||||||
fileCount?: number;
|
|
||||||
mandateId?: string;
|
|
||||||
featureInstanceId?: string;
|
|
||||||
createdAt?: number;
|
|
||||||
scope?: string;
|
|
||||||
neutralize?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchFolders(
|
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
parentId?: string | null
|
groupId: string,
|
||||||
): Promise<FolderInfo[]> {
|
scope: string
|
||||||
const params: any = {};
|
|
||||||
if (parentId !== undefined && parentId !== null) {
|
|
||||||
params.parentId = parentId;
|
|
||||||
}
|
|
||||||
const data = await request({
|
|
||||||
url: '/api/files/folders',
|
|
||||||
method: 'get',
|
|
||||||
params,
|
|
||||||
});
|
|
||||||
return Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createFolder(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
name: string,
|
|
||||||
parentId?: string | null
|
|
||||||
): Promise<FolderInfo> {
|
|
||||||
return await request({
|
|
||||||
url: '/api/files/folders',
|
|
||||||
method: 'post',
|
|
||||||
data: { name, parentId: parentId || null },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function renameFolder(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
folderId: string,
|
|
||||||
name: string
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/files/folders/${folderId}`,
|
url: `/api/files/groups/${groupId}/scope`,
|
||||||
method: 'put',
|
method: 'patch',
|
||||||
data: { name },
|
data: { scope },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteFolderApi(
|
/** Patch neutralize for all files in a group (recursive, incl. knowledge purge/reindex) */
|
||||||
|
export async function patchGroupNeutralize(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
folderId: string,
|
groupId: string,
|
||||||
recursive: boolean = false
|
neutralize: boolean
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/files/folders/${folderId}`,
|
url: `/api/files/groups/${groupId}/neutralize`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { neutralize },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download all files in a group as ZIP */
|
||||||
|
export async function downloadGroupZip(groupId: string): Promise<void> {
|
||||||
|
const { default: api } = await import('../api');
|
||||||
|
const response = await api.get(`/api/files/groups/${groupId}/download`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
const url = window.URL.createObjectURL(response.data);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `group-${groupId}.zip`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a group and optionally all its files */
|
||||||
|
export async function deleteGroup(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
groupId: string,
|
||||||
|
deleteItems: boolean = false
|
||||||
|
): Promise<any> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/groups/${groupId}`,
|
||||||
method: 'delete',
|
method: 'delete',
|
||||||
params: { recursive },
|
params: { deleteItems },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function moveFolder(
|
/** @deprecated Group tree removed — use view-based grouping (viewKey). Returns empty array. */
|
||||||
request: ApiRequestFunction,
|
export function collectGroupItemIds(
|
||||||
folderId: string,
|
_groupTree: Array<{ id: string; itemIds: string[]; subGroups: any[] }>,
|
||||||
targetParentId: string | null
|
_groupId: string
|
||||||
): Promise<any> {
|
): string[] {
|
||||||
return await request({
|
const collect = (): string[] | null => null;
|
||||||
url: `/api/files/folders/${folderId}/move`,
|
return collect() ?? [];
|
||||||
method: 'post',
|
|
||||||
data: { targetParentId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function moveFile(
|
|
||||||
request: ApiRequestFunction,
|
|
||||||
fileId: string,
|
|
||||||
targetFolderId: string | null
|
|
||||||
): Promise<any> {
|
|
||||||
return await request({
|
|
||||||
url: `/api/files/${fileId}/move`,
|
|
||||||
method: 'post',
|
|
||||||
data: { targetFolderId },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The following operations require special handling (FormData, blob responses)
|
// Note: The following operations require special handling (FormData, blob responses)
|
||||||
|
|
@ -299,3 +271,121 @@ export async function moveFile(
|
||||||
// - previewFile: Requires flexible responseType (json or blob)
|
// - previewFile: Requires flexible responseType (json or blob)
|
||||||
// These are kept in the hooks for now due to their special requirements
|
// These are kept in the hooks for now due to their special requirements
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FOLDER TYPES & API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface FolderInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentId: string | null;
|
||||||
|
mandateId: string;
|
||||||
|
featureInstanceId: string;
|
||||||
|
scope: string;
|
||||||
|
neutralize: boolean;
|
||||||
|
contextOrphan?: boolean;
|
||||||
|
sysCreatedBy?: string;
|
||||||
|
sysCreatedAt?: number;
|
||||||
|
sysModifiedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFolderTree(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
owner: 'me' | 'shared' = 'me',
|
||||||
|
): Promise<FolderInfo[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: '/api/files/folders/tree',
|
||||||
|
method: 'get',
|
||||||
|
params: { owner },
|
||||||
|
});
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
name: string,
|
||||||
|
parentId?: string | null,
|
||||||
|
): Promise<FolderInfo> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/files/folders',
|
||||||
|
method: 'post',
|
||||||
|
data: { name, parentId: parentId ?? null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
name: string,
|
||||||
|
): Promise<FolderInfo> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFolder(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): Promise<FolderInfo> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}/move`,
|
||||||
|
method: 'post',
|
||||||
|
data: { parentId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteFolderCascade(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<{ deletedFolders: number; deletedFiles: number }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}`,
|
||||||
|
method: 'delete',
|
||||||
|
params: { cascade: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchFolderScope(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
scope: string,
|
||||||
|
cascadeToFiles: boolean = false,
|
||||||
|
): Promise<{ folderId: string; scope: string; filesUpdated: number }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}/scope`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { scope, cascadeToFiles },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchFolderNeutralize(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
folderId: string,
|
||||||
|
neutralize: boolean,
|
||||||
|
): Promise<{ folderId: string; neutralize: boolean; filesUpdated: number }> {
|
||||||
|
return await request({
|
||||||
|
url: `/api/files/folders/${folderId}/neutralize`,
|
||||||
|
method: 'patch',
|
||||||
|
data: { neutralize },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFiles(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
fileIds: string[],
|
||||||
|
targetFolderId: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(
|
||||||
|
fileIds.map((fileId) =>
|
||||||
|
request({
|
||||||
|
url: `/api/files/${fileId}`,
|
||||||
|
method: 'put',
|
||||||
|
data: { folderId: targetFolderId },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -46,6 +48,7 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -84,6 +87,7 @@ export async function fetchMandates(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Neutralization API
|
* Neutralization API
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -49,6 +51,8 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
|
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -59,6 +63,8 @@ export interface PaginatedResponse<T> {
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
};
|
};
|
||||||
|
groupLayout?: import('./connectionApi').GroupLayout;
|
||||||
|
appliedView?: { viewKey?: string; displayName?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreatePromptData {
|
export interface CreatePromptData {
|
||||||
|
|
@ -110,6 +116,8 @@ export async function fetchPrompts(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
|
|
|
||||||
400
src/api/redmineApi.ts
Normal file
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';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Store API
|
* Store API
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -42,6 +44,53 @@ export interface MandateSubscription {
|
||||||
snapshotPricePerUserCHF: number;
|
snapshotPricePerUserCHF: number;
|
||||||
snapshotPricePerInstanceCHF: number;
|
snapshotPricePerInstanceCHF: number;
|
||||||
stripeSubscriptionId: string | null;
|
stripeSubscriptionId: string | null;
|
||||||
|
isEnterprise?: boolean;
|
||||||
|
enterpriseFlatPriceCHF?: number | null;
|
||||||
|
enterpriseMaxUsers?: number | null;
|
||||||
|
enterpriseMaxFeatureInstances?: number | null;
|
||||||
|
enterpriseMaxDataVolumeMB?: number | null;
|
||||||
|
enterpriseBudgetAiCHF?: number | null;
|
||||||
|
enterpriseNote?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface EnterpriseCreateParams {
|
||||||
|
mandateId: string;
|
||||||
|
startDate: number;
|
||||||
|
endDate: number;
|
||||||
|
autoRenew: boolean;
|
||||||
|
flatPriceCHF: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
|
maxFeatureInstances?: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseRenewParams {
|
||||||
|
subscriptionId: string;
|
||||||
|
newEndDate: number;
|
||||||
|
autoRenew?: boolean;
|
||||||
|
flatPriceCHF?: number;
|
||||||
|
maxUsers?: number | null;
|
||||||
|
maxFeatureInstances?: number | null;
|
||||||
|
maxDataVolumeMB?: number | null;
|
||||||
|
budgetAiCHF?: number | null;
|
||||||
|
note?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnterpriseUpdateParams {
|
||||||
|
subscriptionId: string;
|
||||||
|
enterpriseFlatPriceCHF?: number;
|
||||||
|
enterpriseMaxUsers?: number | null;
|
||||||
|
enterpriseMaxFeatureInstances?: number | null;
|
||||||
|
enterpriseMaxDataVolumeMB?: number | null;
|
||||||
|
enterpriseBudgetAiCHF?: number | null;
|
||||||
|
enterpriseNote?: string | null;
|
||||||
|
recurring?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionUsage {
|
export interface SubscriptionUsage {
|
||||||
|
|
@ -154,3 +203,40 @@ export async function verifyCheckout(
|
||||||
additionalConfig: _mandateConfig(mandateId),
|
additionalConfig: _mandateConfig(mandateId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Enterprise API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function createEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseCreateParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/create',
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renewEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseRenewParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/renew',
|
||||||
|
method: 'post',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEnterprise(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
params: EnterpriseUpdateParams,
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return await request({
|
||||||
|
url: '/api/subscription/enterprise/update',
|
||||||
|
method: 'put',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
61
src/api/tableViewApi.ts
Normal file
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 api from '../api';
|
||||||
import type { VoiceOption } from './voiceCatalogApi';
|
import type { VoiceOption } from './voiceCatalogApi';
|
||||||
|
|
||||||
|
|
@ -9,6 +11,7 @@ export interface TeamsbotSession {
|
||||||
id: string;
|
id: string;
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
mandateId: string;
|
mandateId: string;
|
||||||
|
moduleId?: string;
|
||||||
meetingLink: string;
|
meetingLink: string;
|
||||||
botName: string;
|
botName: string;
|
||||||
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
|
||||||
|
|
@ -70,6 +73,7 @@ export interface TeamsbotConfig {
|
||||||
triggerCooldownSeconds: number;
|
triggerCooldownSeconds: number;
|
||||||
contextWindowSegments: number;
|
contextWindowSegments: number;
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
|
avatarFileId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamsbotSessionStats {
|
export interface TeamsbotSessionStats {
|
||||||
|
|
@ -83,6 +87,7 @@ export interface TeamsbotSessionStats {
|
||||||
export interface StartSessionRequest {
|
export interface StartSessionRequest {
|
||||||
meetingLink: string;
|
meetingLink: string;
|
||||||
botName?: string;
|
botName?: string;
|
||||||
|
moduleId?: string;
|
||||||
connectionId?: string;
|
connectionId?: string;
|
||||||
joinMode?: TeamsbotJoinMode;
|
joinMode?: TeamsbotJoinMode;
|
||||||
sessionContext?: string;
|
sessionContext?: string;
|
||||||
|
|
@ -101,6 +106,7 @@ export interface ConfigUpdateRequest {
|
||||||
triggerCooldownSeconds?: number;
|
triggerCooldownSeconds?: number;
|
||||||
contextWindowSegments?: number;
|
contextWindowSegments?: number;
|
||||||
debugMode?: boolean;
|
debugMode?: boolean;
|
||||||
|
avatarFileId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Voice option type re-exported from the central voice catalog API
|
// Voice option type re-exported from the central voice catalog API
|
||||||
|
|
@ -169,11 +175,63 @@ export interface MfaChallengeEvent {
|
||||||
|
|
||||||
// SSE Event Types
|
// SSE Event Types
|
||||||
export interface TeamsbotSSEEvent {
|
export interface TeamsbotSSEEvent {
|
||||||
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
|
type:
|
||||||
|
| 'transcript'
|
||||||
|
| 'botResponse'
|
||||||
|
| 'analysis'
|
||||||
|
| 'suggestedResponse'
|
||||||
|
| 'statusChange'
|
||||||
|
| 'error'
|
||||||
|
| 'ping'
|
||||||
|
| 'sessionState'
|
||||||
|
| 'ttsDeliveryStatus'
|
||||||
|
| 'mfaChallenge'
|
||||||
|
| 'mfaResolved'
|
||||||
|
| 'chatSendFailed'
|
||||||
|
| 'directorPrompt'
|
||||||
|
| 'agentRun'
|
||||||
|
| 'botConnectionState';
|
||||||
data: any;
|
data: any;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Director Prompts (private operator instructions during a live meeting)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
export type DirectorPromptMode = 'oneShot' | 'persistent';
|
||||||
|
export type DirectorPromptStatus =
|
||||||
|
| 'queued'
|
||||||
|
| 'running'
|
||||||
|
| 'succeeded'
|
||||||
|
| 'failed'
|
||||||
|
| 'consumed';
|
||||||
|
|
||||||
|
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
|
||||||
|
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
|
||||||
|
|
||||||
|
export interface DirectorPrompt {
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
instanceId: string;
|
||||||
|
operatorUserId: string;
|
||||||
|
text: string;
|
||||||
|
mode: DirectorPromptMode;
|
||||||
|
fileIds: string[];
|
||||||
|
status: DirectorPromptStatus;
|
||||||
|
statusMessage?: string;
|
||||||
|
createdAt: string;
|
||||||
|
consumedAt?: string;
|
||||||
|
agentRunId?: string;
|
||||||
|
responseText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectorPromptCreateRequest {
|
||||||
|
text: string;
|
||||||
|
mode: DirectorPromptMode;
|
||||||
|
fileIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// API FUNCTIONS
|
// API FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -289,6 +347,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new system bot account. The password is encrypted server-side
|
||||||
|
* before storage; the API never returns the password back. SysAdmin only.
|
||||||
|
*/
|
||||||
|
export async function createSystemBot(
|
||||||
|
instanceId: string,
|
||||||
|
payload: { email: string; password: string; name?: string },
|
||||||
|
): Promise<{ bot: SystemBot }> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a system bot account. SysAdmin only.
|
||||||
|
*/
|
||||||
|
export async function deleteSystemBot(
|
||||||
|
instanceId: string,
|
||||||
|
botId: string,
|
||||||
|
): Promise<{ deleted: boolean }> {
|
||||||
|
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
|
||||||
*/
|
*/
|
||||||
|
|
@ -386,6 +467,13 @@ export function createSessionStream(instanceId: string, sessionId: string): Even
|
||||||
return new EventSource(url, { withCredentials: true });
|
return new EventSource(url, { withCredentials: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SSE dashboard stream: periodic { type: 'dashboardState', sessions, modules } */
|
||||||
|
export function createDashboardStream(instanceId: string): EventSource {
|
||||||
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
|
const url = `${baseUrl}/api/teamsbot/${instanceId}/dashboard/stream`;
|
||||||
|
return new EventSource(url, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Debug Screenshots (SysAdmin only)
|
// Debug Screenshots (SysAdmin only)
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -452,3 +540,127 @@ export async function submitMfaCode(
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Director Prompts
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a private director prompt to the running bot. Triggers the full
|
||||||
|
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
|
||||||
|
*/
|
||||||
|
export async function submitDirectorPrompt(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
body: DirectorPromptCreateRequest,
|
||||||
|
): Promise<{ prompt: DirectorPrompt }> {
|
||||||
|
const response = await api.post(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List director prompts for a session (operator's own prompts only).
|
||||||
|
*/
|
||||||
|
export async function listDirectorPrompts(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<{ prompts: DirectorPrompt[] }> {
|
||||||
|
const response = await api.get(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a (typically persistent) director prompt.
|
||||||
|
*/
|
||||||
|
export async function deleteDirectorPrompt(
|
||||||
|
instanceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
promptId: string,
|
||||||
|
): Promise<{ deleted: boolean; promptId: string }> {
|
||||||
|
const response = await api.delete(
|
||||||
|
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Meeting Module API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface MeetingModule {
|
||||||
|
id: string;
|
||||||
|
instanceId: string;
|
||||||
|
mandateId: string;
|
||||||
|
ownerUserId: string;
|
||||||
|
title: string;
|
||||||
|
seriesType: string;
|
||||||
|
defaultBotId?: string;
|
||||||
|
defaultDirectorPrompts?: string;
|
||||||
|
goals?: string;
|
||||||
|
kpiTargets?: string;
|
||||||
|
defaultMeetingLink?: string;
|
||||||
|
defaultBotName?: string;
|
||||||
|
defaultAvatarFileId?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listModules(instanceId: string): Promise<MeetingModule[]> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/modules`);
|
||||||
|
return response.data?.modules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createModule(instanceId: string, body: {
|
||||||
|
title: string; seriesType?: string; defaultBotId?: string; goals?: string; kpiTargets?: string;
|
||||||
|
defaultMeetingLink?: string; defaultBotName?: string; defaultAvatarFileId?: string;
|
||||||
|
}): Promise<MeetingModule> {
|
||||||
|
const response = await api.post(`/api/teamsbot/${instanceId}/modules`, body);
|
||||||
|
return response.data?.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModuleDetail(instanceId: string, moduleId: string): Promise<{ module: MeetingModule; sessions: TeamsbotSession[] }> {
|
||||||
|
const response = await api.get(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateModule(instanceId: string, moduleId: string, body: Partial<MeetingModule>): Promise<MeetingModule> {
|
||||||
|
const response = await api.put(`/api/teamsbot/${instanceId}/modules/${moduleId}`, body);
|
||||||
|
return response.data?.module;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteModule(instanceId: string, moduleId: string): Promise<void> {
|
||||||
|
await api.delete(`/api/teamsbot/${instanceId}/modules/${moduleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MediaFileInfo {
|
||||||
|
id: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listMediaFiles(): Promise<MediaFileInfo[]> {
|
||||||
|
const response = await api.get('/api/files/list', {
|
||||||
|
params: { pagination: JSON.stringify({ pageSize: 500 }) },
|
||||||
|
});
|
||||||
|
const data = response.data;
|
||||||
|
let items: any[];
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
items = data;
|
||||||
|
} else if (Array.isArray(data?.items)) {
|
||||||
|
items = data.items;
|
||||||
|
} else {
|
||||||
|
console.warn('[listMediaFiles] unexpected response shape:', Object.keys(data || {}));
|
||||||
|
items = [];
|
||||||
|
}
|
||||||
|
const filtered = items.filter((f: any) => {
|
||||||
|
const mime = (f.mimeType || '').toLowerCase();
|
||||||
|
return mime.startsWith('image/') || mime.startsWith('video/');
|
||||||
|
});
|
||||||
|
console.log(`[listMediaFiles] ${items.length} total files, ${filtered.length} media files`);
|
||||||
|
return filtered.map((f: any) => ({ id: f.id, fileName: f.fileName, mimeType: f.mimeType }));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Trustee API
|
* Trustee API
|
||||||
*
|
*
|
||||||
|
|
@ -115,6 +117,7 @@ export interface AccountingConnectorInfo {
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -852,16 +855,53 @@ export async function fetchChartOfAccounts(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits a background job that pushes positions to the accounting system and
|
||||||
|
* polls `/api/jobs/{jobId}` until the job reaches a terminal status. Returns
|
||||||
|
* the same `{ total, success, skipped, errors, results }` payload that the
|
||||||
|
* legacy synchronous endpoint used to return -- but does NOT block the user
|
||||||
|
* while the (potentially long) external accounting calls run in the worker.
|
||||||
|
*/
|
||||||
export async function syncPositionsToAccounting(
|
export async function syncPositionsToAccounting(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
positionIds: string[]
|
positionIds: string[],
|
||||||
): Promise<{ total: number; success: number; errors: number; results: any[] }> {
|
opts?: {
|
||||||
return await request({
|
pollMs?: number;
|
||||||
|
/**
|
||||||
|
* `message` is already translated server-side by the job route handler
|
||||||
|
* (`resolveJobMessage`). Render it 1:1; never feed it through `t()`.
|
||||||
|
*/
|
||||||
|
onProgress?: (progress: number, message?: string | null) => void;
|
||||||
|
}
|
||||||
|
): Promise<{ total: number; success: number; skipped?: number; errors: number; results: any[] }> {
|
||||||
|
const submission = await request({
|
||||||
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
url: `${_getTrusteeBaseUrl(instanceId)}/accounting/sync`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: { positionIds }
|
data: { positionIds }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jobId: string | undefined = submission?.jobId;
|
||||||
|
if (!jobId) {
|
||||||
|
throw new Error('Background job could not be started (missing jobId).');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollMs = opts?.pollMs ?? 1500;
|
||||||
|
const TERMINAL = new Set(['SUCCESS', 'ERROR', 'CANCELLED']);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const job = await request({ url: `/api/jobs/${jobId}`, method: 'get' });
|
||||||
|
if (opts?.onProgress) {
|
||||||
|
opts.onProgress(Number(job?.progress ?? 0), job?.progressMessage ?? null);
|
||||||
|
}
|
||||||
|
if (job?.status && TERMINAL.has(job.status)) {
|
||||||
|
if (job.status === 'SUCCESS' && job.result) {
|
||||||
|
return job.result;
|
||||||
|
}
|
||||||
|
throw new Error(job?.errorMessage || 'Sync-Job fehlgeschlagen');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSyncStatus(
|
export async function fetchSyncStatus(
|
||||||
|
|
@ -874,6 +914,91 @@ export async function fetchSyncStatus(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// READ-ONLY DATA TABLE API (Daten-Tabellen page)
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// Generic read-only endpoints for the consolidated data tables view.
|
||||||
|
// All entities are paginated, sortable, filterable via the Unified Filter API
|
||||||
|
// (mode=filterValues / mode=ids); no CRUD writes are exposed by these helpers.
|
||||||
|
|
||||||
|
export interface TrusteeDataAccount { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeDataJournalEntry { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeDataJournalLine { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeDataContact { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeDataAccountBalance { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeAccountingConfigRecord { id: string; [key: string]: any; }
|
||||||
|
export interface TrusteeAccountingSyncRecord { id: string; [key: string]: any; }
|
||||||
|
|
||||||
|
async function _fetchReadOnlyTable<T = any>(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
pathSegment: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<T> | T[]> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getTrusteeBaseUrl(instanceId)}/${pathSegment}`,
|
||||||
|
method: 'get',
|
||||||
|
params: _buildPaginationParams(params),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataAccounts(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeDataAccount> | TrusteeDataAccount[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeDataAccount>(request, instanceId, 'data/accounts', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataJournalEntries(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeDataJournalEntry> | TrusteeDataJournalEntry[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeDataJournalEntry>(request, instanceId, 'data/journal-entries', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataJournalLines(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeDataJournalLine> | TrusteeDataJournalLine[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeDataJournalLine>(request, instanceId, 'data/journal-lines', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataContacts(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeDataContact> | TrusteeDataContact[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeDataContact>(request, instanceId, 'data/contacts', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDataAccountBalances(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeDataAccountBalance> | TrusteeDataAccountBalance[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeDataAccountBalance>(request, instanceId, 'data/account-balances', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAccountingConfigs(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeAccountingConfigRecord> | TrusteeAccountingConfigRecord[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeAccountingConfigRecord>(request, instanceId, 'accounting/configs', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAccountingSyncs(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
params?: PaginationParams
|
||||||
|
): Promise<PaginatedResponse<TrusteeAccountingSyncRecord> | TrusteeAccountingSyncRecord[]> {
|
||||||
|
return _fetchReadOnlyTable<TrusteeAccountingSyncRecord>(request, instanceId, 'accounting/syncs', params);
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportAccountingData(
|
export async function exportAccountingData(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string
|
instanceId: string
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { ApiRequestOptions } from '../hooks/useApi';
|
import { ApiRequestOptions } from '../hooks/useApi';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -48,6 +50,7 @@ export interface PaginationParams {
|
||||||
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
|
||||||
filters?: Record<string, any>;
|
filters?: Record<string, any>;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
viewKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginatedResponse<T> {
|
export interface PaginatedResponse<T> {
|
||||||
|
|
@ -152,6 +155,7 @@ export async function fetchUsers(
|
||||||
if (params.sort) paginationObj.sort = params.sort;
|
if (params.sort) paginationObj.sort = params.sort;
|
||||||
if (params.filters) paginationObj.filters = params.filters;
|
if (params.filters) paginationObj.filters = params.filters;
|
||||||
if (params.search) paginationObj.search = params.search;
|
if (params.search) paginationObj.search = params.search;
|
||||||
|
if (params.viewKey) paginationObj.viewKey = params.viewKey;
|
||||||
|
|
||||||
if (Object.keys(paginationObj).length > 0) {
|
if (Object.keys(paginationObj).length > 0) {
|
||||||
requestParams.pagination = JSON.stringify(paginationObj);
|
requestParams.pagination = JSON.stringify(paginationObj);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Voice / Language Catalog API.
|
* 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
|
* AccessLevelSelect
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* AccessRulesEditor
|
* AccessRulesEditor
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* AccessRulesTable
|
* AccessRulesTable
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* AccessRules Components
|
* 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.
|
* ChatInput -- Shared chat input component.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* ChatMessageList -- Shared chat message display component.
|
* ChatMessageList -- Shared chat message display component.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
export { ChatMessageList } from './ChatMessageList';
|
export { ChatMessageList } from './ChatMessageList';
|
||||||
export type { ChatMessage } from './ChatMessageList';
|
export type { ChatMessage } from './ChatMessageList';
|
||||||
export { ChatInput } from './ChatInput';
|
export { ChatInput } from './ChatInput';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
import { IoIosDownload, IoIosCopy } from 'react-icons/io';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { IoIosDownload } from 'react-icons/io';
|
import { IoIosDownload } from 'react-icons/io';
|
||||||
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
import { Popup, PopupAction } from '../UiComponents/Popup/Popup';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
export { ContentPreview } from './ContentPreview';
|
export { ContentPreview } from './ContentPreview';
|
||||||
export type { ContentPreviewProps } from './ContentPreview';
|
export type { ContentPreviewProps } from './ContentPreview';
|
||||||
export { UrlContentPreview } from './UrlContentPreview';
|
export { UrlContentPreview } from './UrlContentPreview';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface ApplicationRendererProps {
|
interface ApplicationRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface HtmlRendererProps {
|
interface HtmlRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
interface ImageRendererProps {
|
interface ImageRendererProps {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import * as pdfjsLib from 'pdfjs-dist';
|
import * as pdfjsLib from 'pdfjs-dist';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { IoIosWarning } from 'react-icons/io';
|
import { IoIosWarning } from 'react-icons/io';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import styles from '../ContentPreview.module.css';
|
import styles from '../ContentPreview.module.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { renderAsync } from 'docx-preview';
|
import { renderAsync } from 'docx-preview';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
export { JsonRenderer } from './JsonRenderer';
|
export { JsonRenderer } from './JsonRenderer';
|
||||||
export { ImageRenderer } from './ImageRenderer';
|
export { ImageRenderer } from './ImageRenderer';
|
||||||
export { TextRenderer } from './TextRenderer';
|
export { TextRenderer } from './TextRenderer';
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown, FaSitemap } from 'react-icons/fa';
|
import {
|
||||||
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
|
FaPlay,
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
FaSpinner,
|
||||||
|
FaCloudUploadAlt,
|
||||||
|
FaCloudDownloadAlt,
|
||||||
|
FaArchive,
|
||||||
|
FaBookmark,
|
||||||
|
FaCaretDown,
|
||||||
|
FaSave,
|
||||||
|
FaPlus,
|
||||||
|
FaChevronLeft,
|
||||||
|
FaChevronRight,
|
||||||
|
} from 'react-icons/fa';
|
||||||
|
import {
|
||||||
|
HiOutlineMagnifyingGlassMinus,
|
||||||
|
HiOutlineMagnifyingGlassPlus,
|
||||||
|
HiOutlineArrowUturnLeft,
|
||||||
|
HiOutlineArrowUturnRight,
|
||||||
|
HiOutlineTrash,
|
||||||
|
HiOutlineDocumentDuplicate,
|
||||||
|
HiOutlineChatBubbleLeftEllipsis,
|
||||||
|
HiOutlineSquares2X2,
|
||||||
|
} from 'react-icons/hi2';
|
||||||
|
import type { WorkflowDefinition, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowAutomationApi';
|
||||||
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { getUserDataCache } from '../../../utils/userCache';
|
||||||
|
import { Button } from '../../UiComponents/Button';
|
||||||
|
|
||||||
|
const ZOOM_PRESET_PERCENTS = [25, 50, 75, 100, 125, 150, 200, 400] as const;
|
||||||
|
|
||||||
|
export interface CanvasHeaderCanvasEditProps {
|
||||||
|
zoomPercent: number;
|
||||||
|
selectedNodeCount: number;
|
||||||
|
connectionSelected: boolean;
|
||||||
|
stickyNoteSelected: boolean;
|
||||||
|
connectionToolActive: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
onZoomIn: () => void;
|
||||||
|
onZoomOut: () => void;
|
||||||
|
onZoomPercentCommit: (percent: number) => void;
|
||||||
|
onFitWindow: () => void;
|
||||||
|
onResetView: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onRedo: () => void;
|
||||||
|
onDeleteSelection: () => void;
|
||||||
|
onDuplicateNode: () => void;
|
||||||
|
onToggleConnectionTool: () => void;
|
||||||
|
/** Textnotiz auf die Canvas legen (ohne Workflow-Daten). */
|
||||||
|
onAddCanvasComment: () => void;
|
||||||
|
/** Verschachtelte Rasterpfade (4.1 / 4.2 …); Haftnotizen unberührt. */
|
||||||
|
onArrangeNodes: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface CanvasHeaderProps {
|
interface CanvasHeaderProps {
|
||||||
workflows: Automation2Workflow[];
|
workflows: WorkflowDefinition[];
|
||||||
currentWorkflowId: string | null;
|
currentWorkflowId: string | null;
|
||||||
onWorkflowSelect: (workflowId: string | null) => void;
|
onWorkflowSelect: (workflowId: string | null) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onExecute: () => void;
|
onExecute: () => void;
|
||||||
onWorkflowSettings?: () => void;
|
onToggleWorkspacePanel?: () => void;
|
||||||
onToggleChat?: () => void;
|
workspacePanelOpen?: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
executing: boolean;
|
executing: boolean;
|
||||||
hasNodes: boolean;
|
hasNodes: boolean;
|
||||||
|
/** When set, required-field graph errors block a normal run; message is the
|
||||||
|
* run button tooltip. Click still fires `onExecuteBlockedClick` to focus
|
||||||
|
* the first offending node. */
|
||||||
|
executeBlockedReason?: string | null;
|
||||||
|
onExecuteBlockedClick?: () => void;
|
||||||
executeResult: ExecuteGraphResponse | null;
|
executeResult: ExecuteGraphResponse | null;
|
||||||
versions?: AutoVersion[];
|
versions?: AutoVersion[];
|
||||||
currentVersionId?: string | null;
|
currentVersionId?: string | null;
|
||||||
|
|
@ -33,8 +90,11 @@ interface CanvasHeaderProps {
|
||||||
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
|
||||||
templateSaving?: boolean;
|
templateSaving?: boolean;
|
||||||
onNewFromTemplate?: () => void;
|
onNewFromTemplate?: () => void;
|
||||||
onWorkflowRename?: (workflowId: string, newName: string) => void;
|
/** Sysadmin-only: when true, NodeConfigPanel renders the static
|
||||||
onAutoLayout?: () => void;
|
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
|
||||||
|
verboseSchema?: boolean;
|
||||||
|
onVerboseSchemaChange?: (next: boolean) => void;
|
||||||
|
canvasEdit?: CanvasHeaderCanvasEditProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
|
||||||
|
|
@ -45,17 +105,23 @@ function _getStatusBadge(t: (key: string) => string): Record<string, { label: st
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
const _tb = 'secondary' as const;
|
||||||
|
const _ts = 'sm' as const;
|
||||||
|
|
||||||
|
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
|
workflows,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
onWorkflowSelect,
|
onWorkflowSelect,
|
||||||
onNew,
|
onNew,
|
||||||
onSave,
|
onSave,
|
||||||
onExecute,
|
onExecute,
|
||||||
onWorkflowSettings,
|
onToggleWorkspacePanel,
|
||||||
onToggleChat,
|
workspacePanelOpen,
|
||||||
saving,
|
saving,
|
||||||
executing,
|
executing,
|
||||||
hasNodes,
|
hasNodes,
|
||||||
|
executeBlockedReason,
|
||||||
|
onExecuteBlockedClick,
|
||||||
executeResult,
|
executeResult,
|
||||||
versions,
|
versions,
|
||||||
currentVersionId,
|
currentVersionId,
|
||||||
|
|
@ -68,10 +134,12 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
onSaveAsTemplate,
|
onSaveAsTemplate,
|
||||||
templateSaving,
|
templateSaving,
|
||||||
onNewFromTemplate,
|
onNewFromTemplate,
|
||||||
onWorkflowRename,
|
verboseSchema,
|
||||||
onAutoLayout,
|
onVerboseSchemaChange,
|
||||||
|
canvasEdit,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
|
||||||
const statusBadge = _getStatusBadge(t);
|
const statusBadge = _getStatusBadge(t);
|
||||||
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
const currentVersion = versions?.find((v) => v.id === currentVersionId);
|
||||||
const currentStatus = currentVersion?.status || 'draft';
|
const currentStatus = currentVersion?.status || 'draft';
|
||||||
|
|
@ -83,38 +151,20 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
|
||||||
const [nameValue, setNameValue] = useState('');
|
const zoomMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
const [zoomInputDraft, setZoomInputDraft] = useState('');
|
||||||
|
|
||||||
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
|
|
||||||
|
|
||||||
const _startNameEdit = useCallback(() => {
|
|
||||||
if (!currentWorkflowId || !onWorkflowRename) return;
|
|
||||||
setNameValue(currentWorkflow?.label || '');
|
|
||||||
setEditingName(true);
|
|
||||||
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
|
||||||
|
|
||||||
const _commitNameEdit = useCallback(() => {
|
|
||||||
setEditingName(false);
|
|
||||||
const trimmed = nameValue.trim();
|
|
||||||
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
|
|
||||||
if (trimmed !== currentWorkflow?.label) {
|
|
||||||
onWorkflowRename(currentWorkflowId, trimmed);
|
|
||||||
}
|
|
||||||
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editingName && nameInputRef.current) {
|
const zp = canvasEdit?.zoomPercent;
|
||||||
nameInputRef.current.focus();
|
if (zp !== undefined) setZoomInputDraft(String(zp));
|
||||||
nameInputRef.current.select();
|
}, [canvasEdit?.zoomPercent]);
|
||||||
}
|
|
||||||
}, [editingName]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _handleClickOutside = (e: MouseEvent) => {
|
const _handleClickOutside = (e: MouseEvent) => {
|
||||||
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
|
||||||
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
|
||||||
|
if (zoomMenuRef.current && !zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', _handleClickOutside);
|
document.addEventListener('mousedown', _handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
return () => document.removeEventListener('mousedown', _handleClickOutside);
|
||||||
|
|
@ -130,185 +180,376 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const _panelOpen = workspacePanelOpen ?? false;
|
||||||
<div className={styles.canvasHeader}>
|
const _runAriaLabel = executing
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
? t('Ausführen…')
|
||||||
{/* Workflow name: inline editable */}
|
: executeBlockedReason
|
||||||
{currentWorkflowId && currentWorkflow ? (
|
? t('Pflicht-Felder fehlen')
|
||||||
editingName ? (
|
: t('Ausführen');
|
||||||
<input
|
const _runTitle = executeBlockedReason ?? (hasNodes ? t('Ausführen') : t('Keine Nodes zum Ausführen.'));
|
||||||
ref={nameInputRef}
|
|
||||||
value={nameValue}
|
|
||||||
onChange={(e) => setNameValue(e.target.value)}
|
|
||||||
onBlur={_commitNameEdit}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
|
|
||||||
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<h4
|
|
||||||
className={styles.canvasTitle}
|
|
||||||
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
|
|
||||||
onClick={_startNameEdit}
|
|
||||||
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
|
|
||||||
>
|
|
||||||
{currentWorkflow.label}
|
|
||||||
</h4>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
|
|
||||||
{t('Neuer Workflow')}
|
|
||||||
</h4>
|
|
||||||
)}
|
|
||||||
{onWorkflowSettings && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.canvasGearBtn}
|
|
||||||
title={t('Workflowkonfiguration Einstieg/Starts')}
|
|
||||||
aria-label={t('Workflow-Konfiguration')}
|
|
||||||
onClick={onWorkflowSettings}
|
|
||||||
>
|
|
||||||
<FaCog />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Split "Neu" button */}
|
const _executeBannerSegmentClass = !executeResult
|
||||||
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
? ''
|
||||||
<div style={{ display: 'flex' }}>
|
: executeResult.success
|
||||||
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
|
? executeResult.warning
|
||||||
{t('Neu')}
|
? styles.canvasHeaderExecuteBannerWarning
|
||||||
</button>
|
: styles.canvasHeaderExecuteBannerSuccess
|
||||||
<button
|
: executeResult.paused
|
||||||
|
? styles.canvasHeaderExecuteBannerPaused
|
||||||
|
: styles.canvasHeaderExecuteBannerError;
|
||||||
|
|
||||||
|
const _commitZoomDraft = () => {
|
||||||
|
if (!canvasEdit) return;
|
||||||
|
const raw = zoomInputDraft.replace(/%/g, '').replace(',', '.').trim();
|
||||||
|
const n = parseFloat(raw);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
setZoomInputDraft(String(canvasEdit.zoomPercent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
canvasEdit.onZoomPercentCommit(Math.min(400, Math.max(25, Math.round(n))));
|
||||||
|
setZoomMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _canDeleteSelection =
|
||||||
|
!!canvasEdit &&
|
||||||
|
(canvasEdit.selectedNodeCount > 0 ||
|
||||||
|
canvasEdit.connectionSelected ||
|
||||||
|
canvasEdit.stickyNoteSelected);
|
||||||
|
const _singleNodeOnly =
|
||||||
|
!!canvasEdit && canvasEdit.selectedNodeCount === 1 && !canvasEdit.connectionSelected;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.canvasHeader} data-suppress-flow-node-hotkeys="">
|
||||||
|
<div
|
||||||
|
className={styles.canvasHeaderToolbar}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('Workflow-Aktionen')}
|
||||||
|
>
|
||||||
|
{onToggleWorkspacePanel && (
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
onClick={() => setNewMenuOpen((p) => !p)}
|
size={_ts}
|
||||||
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
|
icon={_panelOpen ? FaChevronLeft : FaChevronRight}
|
||||||
title={t('Neu aus Vorlage')}
|
className={styles.canvasHeaderIconBtn}
|
||||||
>
|
onClick={onToggleWorkspacePanel}
|
||||||
<FaCaretDown style={{ fontSize: '0.7rem' }} />
|
title={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
||||||
</button>
|
aria-label={_panelOpen ? t('Workspace-Panel ausblenden') : t('Workspace-Panel öffnen')}
|
||||||
</div>
|
/>
|
||||||
{newMenuOpen && (
|
)}
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||||
<button
|
<div className={styles.canvasHeaderSplitPair}>
|
||||||
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onNew(); setNewMenuOpen(false); }}
|
variant={_tb}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
|
size={_ts}
|
||||||
>
|
icon={FaPlus}
|
||||||
{t('Leerer Workflow')}
|
className={`${styles.canvasHeaderIconBtn} ${onNewFromTemplate ? styles.canvasHeaderNewSplitMain : ''}`}
|
||||||
</button>
|
onClick={onNew}
|
||||||
|
title={t('Neuer leerer Workflow')}
|
||||||
|
aria-label={t('Neuer leerer Workflow')}
|
||||||
|
/>
|
||||||
{onNewFromTemplate && (
|
{onNewFromTemplate && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaCaretDown}
|
||||||
|
className={`${styles.canvasHeaderIconBtn} ${styles.canvasHeaderNewSplitMenu}`}
|
||||||
|
onClick={() => setNewMenuOpen((p) => !p)}
|
||||||
|
title={t('Aus Vorlage…')}
|
||||||
|
aria-label={t('Neu aus Vorlage')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={newMenuOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{newMenuOpen && onNewFromTemplate && (
|
||||||
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
|
className={styles.canvasHeaderMenuItem}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
|
onClick={() => {
|
||||||
|
onNewFromTemplate();
|
||||||
|
setNewMenuOpen(false);
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
>
|
>
|
||||||
{t('Aus Vorlage…')}
|
{t('Aus Vorlage…')}
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className={styles.canvasHeaderWorkflowSelect}
|
||||||
|
value={currentWorkflowId ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value ? e.target.value : null;
|
||||||
|
onWorkflowSelect(id);
|
||||||
|
}}
|
||||||
|
aria-label={t('Workflow laden')}
|
||||||
|
title={t('Workflow laden')}
|
||||||
|
>
|
||||||
|
<option value="">{t('Workflow laden')}</option>
|
||||||
|
{workflows.map((w) => (
|
||||||
|
<option key={w.id} value={w.id}>
|
||||||
|
{w.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={saving ? undefined : FaSave}
|
||||||
|
className={styles.canvasHeaderIconBtn}
|
||||||
|
loading={saving}
|
||||||
|
disabled={saving}
|
||||||
|
onClick={onSave}
|
||||||
|
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : t('Speichern')}
|
||||||
|
aria-label={t('Speichern')}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={executing ? undefined : FaPlay}
|
||||||
|
loading={executing}
|
||||||
|
disabled={executing || !hasNodes}
|
||||||
|
className={`${styles.canvasHeaderIconBtn} ${executeBlockedReason ? styles.canvasHeaderRunBlocked : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (executeBlockedReason) {
|
||||||
|
onExecuteBlockedClick?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onExecute();
|
||||||
|
}}
|
||||||
|
aria-label={_runAriaLabel}
|
||||||
|
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
|
||||||
|
title={_runTitle}
|
||||||
|
/>
|
||||||
|
{currentWorkflowId && onSaveAsTemplate && (
|
||||||
|
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaBookmark}
|
||||||
|
loading={templateSaving}
|
||||||
|
disabled={templateSaving}
|
||||||
|
onClick={() => setTemplateMenuOpen((p) => !p)}
|
||||||
|
title={t('Als Vorlage speichern')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={templateMenuOpen}
|
||||||
|
>
|
||||||
|
{t('Als Vorlage')}
|
||||||
|
</Button>
|
||||||
|
{templateMenuOpen && (
|
||||||
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
|
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
onSaveAsTemplate(s);
|
||||||
|
setTemplateMenuOpen(false);
|
||||||
|
}}
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
{scopeLabels[s]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
{_isSysAdmin && onVerboseSchemaChange && (
|
||||||
type="button"
|
<label
|
||||||
className={styles.retryButton}
|
className={styles.canvasHeaderSysadmin}
|
||||||
onClick={onSave}
|
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
|
||||||
disabled={saving || !hasNodes}
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!verboseSchema}
|
||||||
|
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
|
||||||
|
className={styles.canvasHeaderSysadminInput}
|
||||||
|
/>
|
||||||
|
{t('Schema-Details')}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canvasEdit && (
|
||||||
|
<div
|
||||||
|
className={styles.canvasHeaderEditRow}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('Canvas bearbeiten')}
|
||||||
>
|
>
|
||||||
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
|
<div ref={zoomMenuRef} className={styles.canvasHeaderZoomCombo}>
|
||||||
</button>
|
<div className={styles.canvasHeaderZoomInputWrap}>
|
||||||
|
<input
|
||||||
{onAutoLayout && (
|
type="text"
|
||||||
<button
|
inputMode="numeric"
|
||||||
type="button"
|
className={styles.canvasHeaderZoomInput}
|
||||||
className={styles.retryButton}
|
value={zoomInputDraft}
|
||||||
onClick={onAutoLayout}
|
onChange={(e) => setZoomInputDraft(e.target.value)}
|
||||||
disabled={!hasNodes}
|
onBlur={_commitZoomDraft}
|
||||||
title={t('Knoten automatisch anordnen')}
|
onKeyDown={(e) => {
|
||||||
>
|
if (e.key === 'Enter') {
|
||||||
<FaSitemap style={{ marginRight: '0.4rem' }} />
|
e.preventDefault();
|
||||||
{t('Anordnen')}
|
_commitZoomDraft();
|
||||||
</button>
|
}
|
||||||
)}
|
}}
|
||||||
|
aria-label={t('Zoomstufe (Prozent)')}
|
||||||
{/* Save as template */}
|
title={t('Zoomstufe (Prozent)')}
|
||||||
{currentWorkflowId && onSaveAsTemplate && (
|
/>
|
||||||
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
|
<span className={styles.canvasHeaderZoomSuffix} aria-hidden>
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
className={styles.canvasHeaderZoomChevronBtn}
|
||||||
onClick={() => setTemplateMenuOpen((p) => !p)}
|
onClick={() => setZoomMenuOpen((p) => !p)}
|
||||||
disabled={templateSaving}
|
aria-label={t('Zoom-Voreinstellungen')}
|
||||||
title={t('Als Vorlage speichern')}
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={zoomMenuOpen}
|
||||||
|
title={t('Zoom-Voreinstellungen')}
|
||||||
>
|
>
|
||||||
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
|
<FaCaretDown aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
{templateMenuOpen && (
|
{zoomMenuOpen && (
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
|
<div className={styles.canvasHeaderMenuDropdown} role="menu">
|
||||||
{(['user', 'instance', 'mandate'] as const).map((s) => (
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderMenuItem}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
canvasEdit.onFitWindow();
|
||||||
|
setZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Ansicht an Fenster anpassen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderMenuItem}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
canvasEdit.onResetView();
|
||||||
|
setZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Ansicht zurücksetzen')}
|
||||||
|
</button>
|
||||||
|
{ZOOM_PRESET_PERCENTS.map((pct) => (
|
||||||
<button
|
<button
|
||||||
key={s}
|
key={pct}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
|
className={styles.canvasHeaderMenuItem}
|
||||||
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
canvasEdit.onZoomPercentCommit(pct);
|
||||||
|
setZoomMenuOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{scopeLabels[s]}
|
{pct}%
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
<select
|
type="button"
|
||||||
value={currentWorkflowId ?? ''}
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
onChange={(e) => {
|
onClick={canvasEdit.onZoomIn}
|
||||||
const id = e.target.value ? e.target.value : null;
|
title={t('Vergrößern')}
|
||||||
onWorkflowSelect(id);
|
aria-label={t('Vergrößern')}
|
||||||
}}
|
>
|
||||||
style={{ padding: '0.4rem', minWidth: 180 }}
|
<HiOutlineMagnifyingGlassPlus size={18} strokeWidth={2} aria-hidden />
|
||||||
>
|
|
||||||
<option value="">{t('Workflow laden')}</option>
|
|
||||||
{workflows.map((w) => (
|
|
||||||
<option key={w.id} value={w.id}>
|
|
||||||
{w.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.retryButton}
|
|
||||||
onClick={onExecute}
|
|
||||||
disabled={executing || !hasNodes}
|
|
||||||
>
|
|
||||||
{executing ? (
|
|
||||||
<>
|
|
||||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
|
||||||
{t('Ausführen…')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
|
||||||
{t('Ausführen')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{onToggleChat && (
|
|
||||||
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
|
|
||||||
<FaDatabase style={{ marginRight: '0.4rem' }} />
|
|
||||||
{t('Workspace')}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
onClick={canvasEdit.onZoomOut}
|
||||||
|
title={t('Verkleinern')}
|
||||||
|
aria-label={t('Verkleinern')}
|
||||||
|
>
|
||||||
|
<HiOutlineMagnifyingGlassMinus size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
disabled={!canvasEdit.canUndo}
|
||||||
|
onClick={canvasEdit.onUndo}
|
||||||
|
title={t('Rückgängig')}
|
||||||
|
aria-label={t('Rückgängig')}
|
||||||
|
>
|
||||||
|
<HiOutlineArrowUturnLeft size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
disabled={!canvasEdit.canRedo}
|
||||||
|
onClick={canvasEdit.onRedo}
|
||||||
|
title={t('Wiederholen')}
|
||||||
|
aria-label={t('Wiederholen')}
|
||||||
|
>
|
||||||
|
<HiOutlineArrowUturnRight size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
disabled={!_canDeleteSelection}
|
||||||
|
onClick={canvasEdit.onDeleteSelection}
|
||||||
|
title={t('Auswahl löschen')}
|
||||||
|
aria-label={t('Auswahl löschen')}
|
||||||
|
>
|
||||||
|
<HiOutlineTrash size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
disabled={!_singleNodeOnly}
|
||||||
|
onClick={canvasEdit.onDuplicateNode}
|
||||||
|
title={t('Knoten duplizieren')}
|
||||||
|
aria-label={t('Knoten duplizieren')}
|
||||||
|
>
|
||||||
|
<HiOutlineDocumentDuplicate size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
disabled={!hasNodes}
|
||||||
|
onClick={canvasEdit.onArrangeNodes}
|
||||||
|
title={t('Knoten im Raster anordnen')}
|
||||||
|
aria-label={t('Knoten im Raster anordnen')}
|
||||||
|
>
|
||||||
|
<HiOutlineSquares2X2 size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.canvasHeaderGhostIconBtn}
|
||||||
|
onClick={canvasEdit.onAddCanvasComment}
|
||||||
|
title={t('Kommentar auf dem Canvas einfügen')}
|
||||||
|
aria-label={t('Kommentar auf dem Canvas einfügen')}
|
||||||
|
>
|
||||||
|
<HiOutlineChatBubbleLeftEllipsis size={18} strokeWidth={2} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Version Selector */}
|
|
||||||
{currentWorkflowId && versions && versions.length > 0 && (
|
{currentWorkflowId && versions && versions.length > 0 && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
|
<div className={styles.canvasHeaderVersionRow}>
|
||||||
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
|
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
|
||||||
<select
|
<select
|
||||||
|
className={styles.canvasHeaderVersionSelect}
|
||||||
value={currentVersionId ?? ''}
|
value={currentVersionId ?? ''}
|
||||||
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
onChange={(e) => onVersionSelect?.(e.target.value || null)}
|
||||||
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
|
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
|
aria-label={t('Version')}
|
||||||
>
|
>
|
||||||
<option value="">{t('Aktuelle')}</option>
|
<option value="">{t('Aktuelle')}</option>
|
||||||
{versions.map((v) => (
|
{versions.map((v) => (
|
||||||
|
|
@ -318,100 +559,94 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<span
|
<span
|
||||||
style={{
|
className={styles.canvasHeaderVersionBadge}
|
||||||
padding: '2px 8px',
|
style={
|
||||||
borderRadius: 10,
|
{
|
||||||
fontSize: '0.75rem',
|
'--canvasHeaderBadgeBg': `${badge.color}22`,
|
||||||
fontWeight: 600,
|
'--canvasHeaderBadgeFg': badge.color,
|
||||||
background: badge.color + '22',
|
} as React.CSSProperties
|
||||||
color: badge.color,
|
}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{badge.label}
|
{badge.label}
|
||||||
</span>
|
</span>
|
||||||
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaCloudUploadAlt}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onPublishVersion(currentVersion.id)}
|
onClick={() => onPublishVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Version veröffentlichen')}
|
title={t('Version veröffentlichen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaCloudUploadAlt style={{ marginRight: 4 }} />
|
|
||||||
{t('Veröffentlichen')}
|
{t('Veröffentlichen')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaCloudDownloadAlt}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onUnpublishVersion(currentVersion.id)}
|
onClick={() => onUnpublishVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Veröffentlichung zurücknehmen')}
|
title={t('Veröffentlichung zurücknehmen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
|
|
||||||
{t('Veröffentlichung aufheben')}
|
{t('Veröffentlichung aufheben')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaArchive}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={() => onArchiveVersion(currentVersion.id)}
|
onClick={() => onArchiveVersion(currentVersion.id)}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Version archivieren')}
|
title={t('Version archivieren')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
<FaArchive style={{ marginRight: 4 }} />
|
{t('Archiv')}
|
||||||
Archiv
|
</Button>
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{onCreateDraft && (
|
{onCreateDraft && (
|
||||||
<button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.retryButton}
|
variant={_tb}
|
||||||
|
size={_ts}
|
||||||
|
icon={FaPlus}
|
||||||
|
className={styles.canvasHeaderVersionAction}
|
||||||
onClick={onCreateDraft}
|
onClick={onCreateDraft}
|
||||||
disabled={versionLoading}
|
disabled={versionLoading}
|
||||||
title={t('Neuen Entwurf erstellen')}
|
title={t('Neuen Entwurf erstellen')}
|
||||||
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
|
|
||||||
>
|
>
|
||||||
+ Entwurf
|
{t('+ Entwurf')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
|
{versionLoading && <FaSpinner className={`${styles.spinner} ${styles.canvasHeaderVersionSpinner}`} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{executeResult && (
|
{executeResult && (
|
||||||
<div
|
<div
|
||||||
style={{
|
className={`${styles.canvasHeaderExecuteBanner} ${_executeBannerSegmentClass}`}
|
||||||
marginTop: '0.5rem',
|
|
||||||
padding: '0.5rem',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
background: executeResult.success
|
|
||||||
? 'rgba(40,167,69,0.15)'
|
|
||||||
: (executeResult as { paused?: boolean }).paused
|
|
||||||
? 'rgba(0,123,255,0.15)'
|
|
||||||
: 'rgba(220,53,69,0.15)',
|
|
||||||
color: executeResult.success
|
|
||||||
? 'var(--success-color,#28a745)'
|
|
||||||
: (executeResult as { paused?: boolean }).paused
|
|
||||||
? 'var(--primary-color,#007bff)'
|
|
||||||
: 'var(--danger-color,#dc3545)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{executeResult.success ? (
|
{executeResult.success ? (
|
||||||
<>{t('Ausführung abgeschlossen')}</>
|
executeResult.warning ? (
|
||||||
) : (executeResult as { paused?: boolean }).paused ? (
|
<>{executeResult.warning}</>
|
||||||
|
) : (
|
||||||
|
<>{t('Ausführung abgeschlossen')}</>
|
||||||
|
)
|
||||||
|
) : executeResult.paused ? (
|
||||||
<>
|
<>
|
||||||
⏸ Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
|
{t('Workflow pausiert. Öffne ')}
|
||||||
Task zu bearbeiten.
|
<strong>{t('Workflows/Tasks')}</strong>
|
||||||
|
{t(' in der Sidebar, um den Task zu bearbeiten.')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>✗ {executeResult.error ?? t('Unbekannter Fehler')}</>
|
<>{executeResult.error ?? t('Unbekannter Fehler')}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* EditorChatPanel
|
* EditorChatPanel
|
||||||
*
|
*
|
||||||
* AI Chat sidebar for the GraphicalEditor.
|
* AI Chat sidebar for the WorkflowAutomation editor.
|
||||||
* Streams responses via SSE (same pattern as Workspace chat).
|
* Streams responses via SSE (same pattern as Workspace chat).
|
||||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||||
* - Files: drag & drop from FolderTree onto input area, or click in UDB
|
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
||||||
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
@ -32,7 +34,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
export interface PendingFile {
|
export interface PendingFile {
|
||||||
fileId: string;
|
fileId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
itemType?: 'file' | 'folder';
|
itemType?: 'file' | 'group';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorDataSource {
|
export interface EditorDataSource {
|
||||||
|
|
@ -87,7 +89,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
// Load persisted chat history from the backend whenever the workflow changes.
|
// Load persisted chat history from the backend whenever the workflow changes.
|
||||||
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
||||||
// returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`.
|
// returned by `GET /api/workflow-automation/{workflowId}/chat/messages`.
|
||||||
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
|
|
@ -99,7 +101,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PersistedEditorChatResponse>(
|
const res = await api.get<PersistedEditorChatResponse>(
|
||||||
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
`/api/workflow-automation/${workflowId}/chat/messages`,
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||||
|
|
@ -166,7 +168,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const cleanup = startSseStream({
|
const cleanup = startSseStream({
|
||||||
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
|
||||||
body,
|
body,
|
||||||
handlers: {
|
handlers: {
|
||||||
onChunk: (event) => {
|
onChunk: (event) => {
|
||||||
|
|
@ -227,7 +229,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
: m));
|
: m));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
abortRef.current?.();
|
abortRef.current?.();
|
||||||
|
|
@ -241,7 +243,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
}, [_handleSend]);
|
}, [_handleSend]);
|
||||||
|
|
||||||
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
const _handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
if (e.dataTransfer.types.includes('application/tree-items')) {
|
if (
|
||||||
|
e.dataTransfer.types.includes('application/tree-items') ||
|
||||||
|
e.dataTransfer.types.includes('application/group-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-id') ||
|
||||||
|
e.dataTransfer.types.includes('application/file-ids')
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = 'copy';
|
||||||
setTreeDropOver(true);
|
setTreeDropOver(true);
|
||||||
|
|
@ -252,6 +259,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
const _handleDrop = useCallback((e: React.DragEvent) => {
|
const _handleDrop = useCallback((e: React.DragEvent) => {
|
||||||
setTreeDropOver(false);
|
setTreeDropOver(false);
|
||||||
|
const groupId = e.dataTransfer.getData('application/group-id');
|
||||||
|
if (groupId) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
|
||||||
if (treeItemsJson) {
|
if (treeItemsJson) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -282,11 +295,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
<span key={pf.fileId} style={{
|
<span key={pf.fileId} style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
padding: '2px 8px', borderRadius: 12, fontSize: 11,
|
||||||
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
|
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
|
||||||
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
|
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
|
||||||
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
|
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
|
||||||
}}>
|
}}>
|
||||||
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
|
||||||
{onRemovePendingFile && (
|
{onRemovePendingFile && (
|
||||||
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
|
||||||
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* EditorWorkflowChatList
|
* EditorWorkflowChatList
|
||||||
*
|
*
|
||||||
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
|
* UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
|
||||||
* as one editor chat session. Lists workflows already loaded by the parent
|
* is treated as one editor chat session. Lists workflows already loaded by the
|
||||||
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
* parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
||||||
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
||||||
* GraphicalEditor data instead of the workspace endpoint.
|
* WorkflowAutomation data instead of the workspace endpoint.
|
||||||
*/
|
*/
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import type { Automation2Workflow } from '../../../api/workflowApi';
|
import type { WorkflowDefinition } from '../../../api/workflowAutomationApi';
|
||||||
|
|
||||||
interface EditorWorkflowChatListProps {
|
interface EditorWorkflowChatListProps {
|
||||||
workflows: Automation2Workflow[];
|
workflows: WorkflowDefinition[];
|
||||||
currentWorkflowId: string | null;
|
currentWorkflowId: string | null;
|
||||||
onSelect: (workflowId: string | null) => void;
|
onSelect: (workflowId: string | null) => void;
|
||||||
onNew: () => void;
|
onNew: () => void;
|
||||||
|
|
@ -48,7 +50,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||||
const list = q
|
const list = q
|
||||||
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
|
||||||
: [...workflows];
|
: [...workflows];
|
||||||
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
|
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
|
||||||
return list;
|
return list;
|
||||||
}, [workflows, search]);
|
}, [workflows, search]);
|
||||||
|
|
||||||
|
|
@ -85,7 +87,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
|
||||||
) : (
|
) : (
|
||||||
filtered.map((wf) => {
|
filtered.map((wf) => {
|
||||||
const isActive = wf.id === currentWorkflowId;
|
const isActive = wf.id === currentWorkflowId;
|
||||||
const ts = wf.lastStartedAt || wf.createdAt;
|
const ts = wf.lastStartedAt || wf.sysCreatedAt;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={wf.id}
|
key={wf.id}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,99 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* NodeConfigPanel - Generic parameter renderer for all node types.
|
* NodeConfigPanel - Generic parameter renderer for all node types.
|
||||||
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
import type { CanvasNode } from './FlowCanvas';
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
|
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowAutomationApi';
|
||||||
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
import type { ApiRequestFunction } from '../../../api/workflowAutomationApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import { ContextBuilderRenderer } from '../nodes/frontendTypeRenderers/ContextBuilderRenderer';
|
||||||
|
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
|
||||||
|
import { findRequiredErrors } from '../nodes/shared/paramValidation';
|
||||||
|
import { useWorkflowDataFlow } from '../context/WorkflowDataFlowContext';
|
||||||
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { AccordionList } from '../../UiComponents/AccordionList';
|
||||||
|
import type { AccordionListItem } from '../../UiComponents/AccordionList';
|
||||||
|
|
||||||
|
const CONTEXT_EXTRACT_CONTENT_NODE_TYPE = 'context.extractContent';
|
||||||
|
const CONTEXT_EXTRACT_CHUNK_PARAM_NAMES = ['chunkSizeUnit', 'chunkSize', 'chunkOverlap'] as const;
|
||||||
|
const CONTEXT_EXTRACT_CHUNK_SET = new Set<string>(CONTEXT_EXTRACT_CHUNK_PARAM_NAMES);
|
||||||
|
|
||||||
|
/** Optional params use stored value only (unset ⇒ no chip). Required uses schema default as fallback. */
|
||||||
|
export function workflowParamUiValue(stored: Record<string, unknown>, param: NodeTypeParameter): unknown {
|
||||||
|
const raw = stored[param.name];
|
||||||
|
if (param.required) {
|
||||||
|
return raw !== undefined && raw !== null ? raw : param.default;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectiveSchemaParamString(name: string, currentParams: Record<string, unknown>, nt: NodeType): string {
|
||||||
|
const raw = currentParams[name];
|
||||||
|
const s = raw !== undefined && raw !== null ? String(raw) : '';
|
||||||
|
if (s !== '') return s;
|
||||||
|
const meta = nt.parameters?.find((p) => p.name === name);
|
||||||
|
const d = meta?.default;
|
||||||
|
return d !== undefined && d !== null ? String(d) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function accordionExtractParamTitle(param: NodeTypeParameter, t: (key: string) => string): React.ReactNode {
|
||||||
|
return (
|
||||||
|
<span style={{ fontWeight: 700, fontSize: 12 }}>
|
||||||
|
{param.required ? (
|
||||||
|
<span style={{ color: 'var(--danger-color, #dc3545)', marginRight: 3 }} title={t('Pflichtfeld')}>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{param.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verboseSchemaTypeBadge(
|
||||||
|
verboseSchema: boolean,
|
||||||
|
param: NodeTypeParameter,
|
||||||
|
t: (key: string) => string,
|
||||||
|
): React.ReactElement | null {
|
||||||
|
if (!verboseSchema || !param.type) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 6,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
title={t('Parameter-Typ')}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{param.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeConfigPanelProps {
|
interface NodeConfigPanelProps {
|
||||||
node: CanvasNode | null;
|
node: CanvasNode | null;
|
||||||
|
|
@ -22,6 +104,38 @@ interface NodeConfigPanelProps {
|
||||||
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
|
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
request?: ApiRequestFunction;
|
request?: ApiRequestFunction;
|
||||||
|
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
|
||||||
|
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
|
||||||
|
verboseSchema?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When ``frontendOptions.dependsOn`` and ``frontendOptions.showWhen`` are set
|
||||||
|
* (same convention as trustee / gateway nodeAdapter ``visibleWhen``), hide the
|
||||||
|
* parameter unless the referenced parameter's effective value matches.
|
||||||
|
*/
|
||||||
|
export function parameterVisibleForFrontendOptions(
|
||||||
|
param: NodeTypeParameter,
|
||||||
|
params: Record<string, unknown>,
|
||||||
|
nodeType: NodeType,
|
||||||
|
): boolean {
|
||||||
|
const fo = param.frontendOptions;
|
||||||
|
if (!fo || typeof fo !== 'object') return true;
|
||||||
|
const dependsOnRaw = fo.dependsOn as unknown;
|
||||||
|
const showWhenRaw = fo.showWhen as unknown;
|
||||||
|
if (typeof dependsOnRaw !== 'string' || dependsOnRaw.length === 0 || showWhenRaw === undefined || showWhenRaw === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const depMeta = nodeType.parameters?.find((p) => p.name === dependsOnRaw);
|
||||||
|
const rawSibling = params[dependsOnRaw];
|
||||||
|
const siblingValue =
|
||||||
|
rawSibling !== undefined && rawSibling !== null ? String(rawSibling) : '';
|
||||||
|
const fallback =
|
||||||
|
depMeta?.default !== undefined && depMeta?.default !== null ? String(depMeta.default) : '';
|
||||||
|
const effective = siblingValue !== '' ? siblingValue : fallback;
|
||||||
|
const allowed: string[] = Array.isArray(showWhenRaw)
|
||||||
|
? showWhenRaw.map((x) => String(x))
|
||||||
|
: [String(showWhenRaw)];
|
||||||
|
return allowed.includes(effective);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
|
|
@ -32,6 +146,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
onNodeUpdate,
|
onNodeUpdate,
|
||||||
instanceId,
|
instanceId,
|
||||||
request,
|
request,
|
||||||
|
verboseSchema = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||||
|
|
@ -55,7 +170,12 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
const updateParam = useCallback(
|
const updateParam = useCallback(
|
||||||
(key: string, value: unknown) => {
|
(key: string, value: unknown) => {
|
||||||
setParams((prev) => {
|
setParams((prev) => {
|
||||||
const next = { ...prev, [key]: value };
|
const next = { ...prev };
|
||||||
|
if (value === undefined) {
|
||||||
|
delete next[key];
|
||||||
|
} else {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
const id = nodeIdRef.current;
|
const id = nodeIdRef.current;
|
||||||
if (id) {
|
if (id) {
|
||||||
if (notifyParentTimeoutRef.current != null) {
|
if (notifyParentTimeoutRef.current != null) {
|
||||||
|
|
@ -72,11 +192,216 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
[onParametersChange]
|
[onParametersChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const patchParams = useCallback(
|
||||||
|
(patch: Record<string, unknown>) => {
|
||||||
|
setParams((prev) => {
|
||||||
|
const next = { ...prev, ...patch };
|
||||||
|
const id = nodeIdRef.current;
|
||||||
|
if (id) {
|
||||||
|
if (notifyParentTimeoutRef.current != null) {
|
||||||
|
clearTimeout(notifyParentTimeoutRef.current);
|
||||||
|
}
|
||||||
|
notifyParentTimeoutRef.current = setTimeout(() => {
|
||||||
|
notifyParentTimeoutRef.current = null;
|
||||||
|
onParametersChange(id, next);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onParametersChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataFlow = useWorkflowDataFlow();
|
||||||
|
const portTypeCatalog: Record<string, PortSchema> = (dataFlow?.portTypeCatalog as Record<string, PortSchema> | undefined) ?? {};
|
||||||
|
|
||||||
|
// Phase-4 Schicht-4 — Pflicht-Params zuerst sortieren, damit der User
|
||||||
|
// nicht nach unten scrollen muss, um zu sehen was fehlt.
|
||||||
|
const sortedParameters: NodeTypeParameter[] = useMemo(() => {
|
||||||
|
const all = nodeType?.parameters ?? [];
|
||||||
|
const required = all.filter((p) => p.required);
|
||||||
|
const optional = all.filter((p) => !p.required);
|
||||||
|
return [...required, ...optional];
|
||||||
|
}, [nodeType?.parameters]);
|
||||||
|
|
||||||
|
// Pre-compute which required params are unbound on this node so we can
|
||||||
|
// surface a panel-level summary banner. The hidden-param safety net lives
|
||||||
|
// inside `findRequiredErrors` so banner, canvas badges and Run-button stay
|
||||||
|
// in lockstep.
|
||||||
|
// Banner labels are kept short (`param.name`); the full description is
|
||||||
|
// attached as the tooltip below.
|
||||||
|
const requiredErrors = useMemo(() => {
|
||||||
|
if (!node || !nodeType) return [];
|
||||||
|
return findRequiredErrors(node, nodeType, (p) => p.name);
|
||||||
|
}, [node, nodeType]);
|
||||||
|
|
||||||
|
// Resolve full descriptions per missing param (for the banner tooltip).
|
||||||
|
const requiredErrorTooltip = useMemo(() => {
|
||||||
|
if (!requiredErrors.length || !nodeType) return '';
|
||||||
|
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
|
||||||
|
return requiredErrors
|
||||||
|
.map((e) => {
|
||||||
|
const p = byName.get(e.paramName);
|
||||||
|
const desc = p ? (getLabel(p.description, language) || '') : '';
|
||||||
|
return desc ? `${e.paramName}: ${desc}` : e.paramName;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}, [requiredErrors, nodeType, language]);
|
||||||
|
|
||||||
|
const extractContentAccordionItems = useMemo((): AccordionListItem<string>[] | null => {
|
||||||
|
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
|
||||||
|
|
||||||
|
const byName = new Map((nodeType.parameters ?? []).map((p) => [p.name, p]));
|
||||||
|
const out: AccordionListItem<string>[] = [];
|
||||||
|
|
||||||
|
for (const param of sortedParameters) {
|
||||||
|
if (param.frontendType === 'hidden') continue;
|
||||||
|
if (param.name === 'context') continue;
|
||||||
|
if (CONTEXT_EXTRACT_CHUNK_SET.has(param.name)) continue;
|
||||||
|
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) continue;
|
||||||
|
|
||||||
|
const usePicker = _shouldUseRequiredPicker(param);
|
||||||
|
if (usePicker) {
|
||||||
|
out.push({
|
||||||
|
id: param.name,
|
||||||
|
title: accordionExtractParamTitle(param, t),
|
||||||
|
children: (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||||
|
<RequiredAttributePicker
|
||||||
|
label={getLabel(param.description, language) || param.name}
|
||||||
|
expectedType={param.type}
|
||||||
|
value={workflowParamUiValue(params, param)}
|
||||||
|
onChange={(val) => updateParam(param.name, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontendType = param.frontendType || 'text';
|
||||||
|
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
|
|
||||||
|
if (param.name === 'outputMode') {
|
||||||
|
const chunksNested = effectiveSchemaParamString('outputMode', params, nodeType) === 'chunks';
|
||||||
|
out.push({
|
||||||
|
id: param.name,
|
||||||
|
title: accordionExtractParamTitle(param, t),
|
||||||
|
children: (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||||
|
<Renderer
|
||||||
|
param={param}
|
||||||
|
value={workflowParamUiValue(params, param)}
|
||||||
|
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||||
|
allParams={params}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
|
hideAccordionTitle
|
||||||
|
/>
|
||||||
|
{chunksNested ? (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<AccordionList<string>
|
||||||
|
key={`extract-chunks-${node.id}`}
|
||||||
|
defaultOpenId={null}
|
||||||
|
items={CONTEXT_EXTRACT_CHUNK_PARAM_NAMES.map((chunkName): AccordionListItem<string> => {
|
||||||
|
const cp = byName.get(chunkName);
|
||||||
|
if (!cp) {
|
||||||
|
return { id: chunkName, title: chunkName, children: <></> };
|
||||||
|
}
|
||||||
|
const ft = cp.frontendType || 'text';
|
||||||
|
const ChunkRenderer = FRONTEND_TYPE_RENDERERS[ft] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
|
return {
|
||||||
|
id: chunkName,
|
||||||
|
title: accordionExtractParamTitle(cp, t),
|
||||||
|
children: (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{verboseSchemaTypeBadge(verboseSchema, cp, t)}
|
||||||
|
<ChunkRenderer
|
||||||
|
param={cp}
|
||||||
|
value={workflowParamUiValue(params, cp)}
|
||||||
|
onChange={(val: unknown) => updateParam(cp.name, val)}
|
||||||
|
allParams={params}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
|
hideAccordionTitle
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
id: param.name,
|
||||||
|
title: accordionExtractParamTitle(param, t),
|
||||||
|
children: (
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
{verboseSchemaTypeBadge(verboseSchema, param, t)}
|
||||||
|
<Renderer
|
||||||
|
param={param}
|
||||||
|
value={workflowParamUiValue(params, param)}
|
||||||
|
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||||
|
allParams={params}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
|
hideAccordionTitle
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}, [
|
||||||
|
sortedParameters,
|
||||||
|
params,
|
||||||
|
nodeType,
|
||||||
|
language,
|
||||||
|
node?.id,
|
||||||
|
node?.type,
|
||||||
|
verboseSchema,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
patchParams,
|
||||||
|
updateParam,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const extractContentContextParam = useMemo((): NodeTypeParameter | null => {
|
||||||
|
if (!node || !nodeType || node.type !== CONTEXT_EXTRACT_CONTENT_NODE_TYPE) return null;
|
||||||
|
const param = sortedParameters.find((p) => p.name === 'context') ?? null;
|
||||||
|
if (!param) return null;
|
||||||
|
if (param.frontendType === 'hidden') return null;
|
||||||
|
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
|
||||||
|
return param;
|
||||||
|
}, [node, nodeType, sortedParameters, params]);
|
||||||
|
|
||||||
if (!node || !nodeType) return null;
|
if (!node || !nodeType) return null;
|
||||||
|
|
||||||
const isTrigger = node.type.startsWith('trigger.');
|
const isTrigger = node.type.startsWith('trigger.');
|
||||||
const showNameField = onNodeUpdate && !isTrigger;
|
const showNameField = onNodeUpdate && !isTrigger;
|
||||||
const parameters = nodeType.parameters || [];
|
const parameters = sortedParameters;
|
||||||
|
|
||||||
|
const inputPortDefs = nodeType.inputPorts ?? {};
|
||||||
|
const outputPortDefs = nodeType.outputPorts ?? {};
|
||||||
|
const inputPortEntries = Object.entries(inputPortDefs);
|
||||||
|
const outputPortEntries = Object.entries(outputPortDefs);
|
||||||
|
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.nodeConfigPanel}>
|
<div className={styles.nodeConfigPanel}>
|
||||||
|
|
@ -101,20 +426,316 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
|
||||||
{getLabel(nodeType.description, language)}
|
{getLabel(nodeType.description, language)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{parameters.map((param: NodeTypeParameter) => {
|
{hasPortInfo && verboseSchema && (
|
||||||
const frontendType = param.frontendType || 'text';
|
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
|
||||||
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
<summary
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '0.15rem 0',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
}}
|
||||||
|
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
|
||||||
|
>
|
||||||
|
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
|
||||||
|
</summary>
|
||||||
|
{inputPortEntries.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||||
|
{'\u2B07'} {t('Eingabe')}
|
||||||
|
</div>
|
||||||
|
{inputPortEntries.map(([idx, def]) => (
|
||||||
|
<_PortFieldList
|
||||||
|
key={`in-${idx}`}
|
||||||
|
portIndex={Number(idx)}
|
||||||
|
schemaNames={def?.accepts ?? []}
|
||||||
|
catalog={portTypeCatalog}
|
||||||
|
emptyLabel={t('keine Felder')}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{outputPortEntries.length > 0 && (
|
||||||
|
<div style={{ marginTop: '0.4rem' }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
|
||||||
|
{'\u2B06'} {t('Ausgabe')}
|
||||||
|
</div>
|
||||||
|
{outputPortEntries.map(([idx, def]) => (
|
||||||
|
<_PortFieldList
|
||||||
|
key={`out-${idx}`}
|
||||||
|
portIndex={Number(idx)}
|
||||||
|
schemaNames={_schemaNamesFromOutputPort(def)}
|
||||||
|
catalog={portTypeCatalog}
|
||||||
|
emptyLabel={t('keine Felder')}
|
||||||
|
language={language}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{requiredErrors.length > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: '6px 10px',
|
||||||
|
background: 'rgba(220,53,69,0.10)',
|
||||||
|
borderLeft: '3px solid var(--danger-color, #dc3545)',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--danger-color, #dc3545)',
|
||||||
|
overflowWrap: 'anywhere',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
title={requiredErrorTooltip || undefined}
|
||||||
|
>
|
||||||
|
{t('Pflicht-Felder ohne Quelle:')}{' '}
|
||||||
|
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{extractContentAccordionItems !== null ? (
|
||||||
|
<>
|
||||||
|
{extractContentContextParam ? (
|
||||||
|
<div
|
||||||
|
key={`${node.id}-${extractContentContextParam.name}`}
|
||||||
|
style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extractContentContextParam.required && (
|
||||||
|
<span
|
||||||
|
title={t('Pflichtfeld')}
|
||||||
|
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{verboseSchema && extractContentContextParam.type && (
|
||||||
|
<span
|
||||||
|
title={t('Parameter-Typ')}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extractContentContextParam.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ContextBuilderRenderer
|
||||||
|
param={extractContentContextParam}
|
||||||
|
value={workflowParamUiValue(params, extractContentContextParam)}
|
||||||
|
onChange={(val: unknown) => updateParam(extractContentContextParam.name, val)}
|
||||||
|
allParams={params}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{extractContentAccordionItems.length > 0 ? (
|
||||||
|
<AccordionList<string>
|
||||||
|
key={`${node.id}-extract-accordion`}
|
||||||
|
defaultOpenId={null}
|
||||||
|
items={extractContentAccordionItems}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
parameters.map((param: NodeTypeParameter) => {
|
||||||
|
// Safety net: hidden params have no UI footprint at all — no row,
|
||||||
|
// no required-mark, no type-badge. Their value is system-set.
|
||||||
|
if (param.frontendType === 'hidden') return null;
|
||||||
|
if (!parameterVisibleForFrontendOptions(param, params, nodeType)) return null;
|
||||||
|
const useRequiredPicker = _shouldUseRequiredPicker(param);
|
||||||
|
if (useRequiredPicker) {
|
||||||
|
return (
|
||||||
|
<div key={param.name} style={{ marginBottom: 8 }}>
|
||||||
|
<RequiredAttributePicker
|
||||||
|
label={getLabel(param.description, language) || param.name}
|
||||||
|
expectedType={param.type}
|
||||||
|
value={workflowParamUiValue(params, param)}
|
||||||
|
onChange={(val) => updateParam(param.name, val)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const frontendType = param.frontendType || 'text';
|
||||||
|
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
|
||||||
|
return (
|
||||||
|
<div key={`${node.id}-${param.name}`} style={{ marginBottom: 4, minWidth: 0, maxWidth: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
marginBottom: 2,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{param.required && (
|
||||||
|
<span
|
||||||
|
title={t('Pflichtfeld')}
|
||||||
|
style={{ color: 'var(--danger-color, #dc3545)', fontWeight: 700, flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{verboseSchema && param.type && (
|
||||||
|
<span
|
||||||
|
title={t('Parameter-Typ')}
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary)',
|
||||||
|
background: 'var(--bg-secondary)',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '1px 6px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{param.type}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Renderer
|
||||||
|
param={param}
|
||||||
|
value={workflowParamUiValue(params, param)}
|
||||||
|
onChange={(val: unknown) => updateParam(param.name, val)}
|
||||||
|
allParams={params}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
onPatchParams={patchParams}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Heuristic: required params with a Schicht-1 catalog type (non-primitive
|
||||||
|
* ref/record/list) get the typed RequiredAttributePicker; primitive scalars
|
||||||
|
* fall through to the legacy frontend-type renderer (text/number/select etc.)
|
||||||
|
* unless they have no frontendType at all and a non-trivial type. */
|
||||||
|
function _shouldUseRequiredPicker(param: NodeTypeParameter): boolean {
|
||||||
|
if (!param.required) return false;
|
||||||
|
if (!param.type) return false;
|
||||||
|
// Hidden params never get a picker — they are system-set or rendered to
|
||||||
|
// nothing on purpose. The render loop above also skips hidden rows entirely.
|
||||||
|
if (param.frontendType === 'hidden') return false;
|
||||||
|
// Always defer to specialized FE renderers when explicitly chosen.
|
||||||
|
if (param.frontendType && _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS.has(param.frontendType)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Catalog ref/record/list types are best handled by RequiredAttributePicker.
|
||||||
|
if (/^(List\[|Dict\[)/.test(param.type)) return true;
|
||||||
|
if (/^[A-Z]/.test(param.type)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _LEGACY_RENDERERS_THAT_HANDLE_BINDINGS = new Set([
|
||||||
|
'userConnection',
|
||||||
|
'featureInstance',
|
||||||
|
'sharepointFolder',
|
||||||
|
'sharepointFile',
|
||||||
|
'userFileFolder',
|
||||||
|
'clickupList',
|
||||||
|
'clickupTask',
|
||||||
|
'dataRef',
|
||||||
|
'caseList',
|
||||||
|
'fieldBuilder',
|
||||||
|
'keyValueRows',
|
||||||
|
'cron',
|
||||||
|
'condition',
|
||||||
|
'mappingTable',
|
||||||
|
'filterExpression',
|
||||||
|
'attachmentBuilder',
|
||||||
|
'json',
|
||||||
|
'modelMultiSelect',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function _schemaNamesFromOutputPort(def: { schema?: string | GraphDefinedSchemaRef } | undefined): string[] {
|
||||||
|
if (!def?.schema) return [];
|
||||||
|
if (typeof def.schema === 'string') return [def.schema];
|
||||||
|
if (typeof def.schema === 'object' && def.schema.kind === 'fromGraph') return ['FormPayload', 'FormPayload_dynamic'];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface _PortFieldListProps {
|
||||||
|
portIndex: number;
|
||||||
|
schemaNames: string[];
|
||||||
|
catalog: Record<string, PortSchema>;
|
||||||
|
emptyLabel: string;
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _PortFieldList: React.FC<_PortFieldListProps> = ({ portIndex, schemaNames, catalog, emptyLabel, language }) => {
|
||||||
|
if (!schemaNames.length) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginLeft: 4, marginBottom: 4 }}>
|
||||||
|
<div style={{ color: 'var(--text-secondary)', fontSize: '0.7rem' }}>
|
||||||
|
{`#${portIndex} `}{schemaNames.join(' | ')}
|
||||||
|
</div>
|
||||||
|
{schemaNames.map((name) => {
|
||||||
|
const schema = catalog[name];
|
||||||
|
const fields = schema?.fields ?? [];
|
||||||
|
if (name === 'Transit') {
|
||||||
|
return (
|
||||||
|
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontStyle: 'italic', fontSize: '0.7rem' }}>
|
||||||
|
{'\u00B7 Transit (durchgereichte Daten)'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!fields.length) {
|
||||||
|
return (
|
||||||
|
<div key={name} style={{ marginLeft: 8, color: 'var(--text-tertiary)', fontSize: '0.7rem' }}>
|
||||||
|
{`\u00B7 ${emptyLabel}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Renderer
|
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
|
||||||
key={param.name}
|
{fields.map((f) => (
|
||||||
param={param}
|
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
|
||||||
value={params[param.name] ?? param.default}
|
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
|
||||||
onChange={(val: unknown) => updateParam(param.name, val)}
|
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
|
||||||
allParams={params}
|
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
|
||||||
instanceId={instanceId}
|
{f.description && (
|
||||||
request={request}
|
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
|
||||||
nodeType={node.type}
|
{getLabel(f.description, language)}
|
||||||
/>
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* NodeListItem - Draggable node type item for the sidebar.
|
* NodeListItem - Draggable node type item for the sidebar.
|
||||||
* Used in both regular categories and I/O sub-groups.
|
* Used in both regular categories and I/O sub-groups.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeType } from '../../../api/workflowApi';
|
import type { NodeType } from '../../../api/workflowAutomationApi';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
import { AiBadge } from '../nodes/shared/AiBadge';
|
import { AiBadge } from '../nodes/shared/AiBadge';
|
||||||
|
|
||||||
interface NodeListItemProps {
|
interface NodeListItemProps {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||||
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
|
* Groups node types by category (start, input, flow, data, ai, email, sharepoint).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
|
import type { NodeType, NodeTypeCategory } from '../../../api/workflowAutomationApi';
|
||||||
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { NodeListItem } from './NodeListItem';
|
import { NodeListItem } from './NodeListItem';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -21,7 +23,7 @@ interface NodeSidebarProps {
|
||||||
language: string;
|
language: string;
|
||||||
expandedCategories: Set<string>;
|
expandedCategories: Set<string>;
|
||||||
onToggleCategory: (id: string) => void;
|
onToggleCategory: (id: string) => void;
|
||||||
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
|
/** Hide palette categories (optional; e.g. feature flags) */
|
||||||
excludedCategories?: Set<string>;
|
excludedCategories?: Set<string>;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* RunTracingPanel
|
* RunTracingPanel
|
||||||
*
|
*
|
||||||
|
|
@ -7,7 +9,7 @@
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import type { AutoStepLog } from '../../../api/workflowApi';
|
import type { AutoStepLog } from '../../../api/workflowAutomationApi';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
|
|
@ -98,7 +100,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
|
url: `/api/workflow-automation/runs/${runId}/steps`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
setSteps(data?.steps || []);
|
setSteps(data?.steps || []);
|
||||||
|
|
@ -115,7 +117,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||||
loadSteps();
|
loadSteps();
|
||||||
|
|
||||||
const baseUrl = api.defaults.baseURL || '';
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
|
const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`;
|
||||||
const es = new EventSource(url, { withCredentials: true });
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
|
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
|
||||||
*/
|
*/
|
||||||
|
|
@ -9,8 +11,8 @@ import {
|
||||||
type AutoWorkflowTemplate,
|
type AutoWorkflowTemplate,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
type ApiRequestFunction,
|
type ApiRequestFunction,
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowAutomationApi';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
interface TemplatePickerProps {
|
interface TemplatePickerProps {
|
||||||
|
|
@ -50,7 +52,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
const scope = activeScope === 'all' ? undefined : activeScope;
|
||||||
const result = await fetchTemplates(request, instanceId, scope);
|
const result = await fetchTemplates(request, scope);
|
||||||
setTemplates(Array.isArray(result) ? result : result.items);
|
setTemplates(Array.isArray(result) ? result : result.items);
|
||||||
} catch {
|
} catch {
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Sidebar with node list + canvas area.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
@ -246,6 +246,7 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
background: var(--canvas-bg, #fafafa);
|
background: var(--canvas-bg, #fafafa);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -254,6 +255,385 @@
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderToolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
|
||||||
|
.canvasHeaderToolbar :global(button),
|
||||||
|
.canvasHeaderToolbar label {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderEditRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e8e8e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderEditRow :global(button) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderGhostIconBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderGhostIconBtn:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderGhostIconBtn:disabled {
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomCombo {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomInputWrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 4.25rem;
|
||||||
|
padding-left: 0.35rem;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
border-right: none;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomInputWrap:focus-within {
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomInput {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 2.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0.28rem 0.15rem 0.28rem 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
text-align: right;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomInput:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomSuffix {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-right: 0.35rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomChevronBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 0 6px 6px 0;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
cursor: pointer;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderZoomChevronBtn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Closed <select> width must not follow the longest option label. */
|
||||||
|
.canvasHeaderWorkflowSelect {
|
||||||
|
flex: 0 1 12.5rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.31rem 0.45rem;
|
||||||
|
min-height: 30px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: var(--button-border-radius, 6px);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderTitleBlock {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderTitle,
|
||||||
|
.canvasHeaderTitle input {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderTitle {
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderTitleMuted {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.65;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderTitle input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border: 1px solid var(--primary-color, #007bff);
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderActionPanel {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
|
flex: 0 1 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderSplitPair :global(.button + .button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderRunBlocked {
|
||||||
|
background: rgba(220, 53, 69, 0.1) !important;
|
||||||
|
border: 1px solid var(--danger-color, #dc3545) !important;
|
||||||
|
color: var(--danger-color, #dc3545) !important;
|
||||||
|
cursor: help !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderRunBlocked:hover:not(:disabled) {
|
||||||
|
filter: brightness(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderRunBlocked :global(.buttonIcon) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionRow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e8e8e8);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionRow :global(.button) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionLabel {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionBadge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--canvasHeaderBadgeBg, transparent);
|
||||||
|
color: var(--canvasHeaderBadgeFg, inherit);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionAction {
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
padding: 0.25rem 0.6rem !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionSpinner {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderExecuteBanner {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderExecuteBannerSuccess {
|
||||||
|
background: rgba(40, 167, 69, 0.15);
|
||||||
|
color: var(--success-color, #28a745);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderExecuteBannerWarning {
|
||||||
|
background: rgba(255, 193, 7, 0.15);
|
||||||
|
color: var(--warning-color, #ffc107);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderExecuteBannerPaused {
|
||||||
|
background: rgba(0, 123, 255, 0.15);
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderExecuteBannerError {
|
||||||
|
background: rgba(220, 53, 69, 0.15);
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderSysadminInput {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderVersionSelect {
|
||||||
|
width: 11rem;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0.3rem 0.45rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-height: 1.9rem;
|
||||||
|
border: 1px solid var(--border-color, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderSysadmin {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border: 1px dashed var(--border-color, #ccc);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderNewSplit {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderSplitPair {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderNewSplitMain {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderNewSplitMenu {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
padding-right: 0.4rem;
|
||||||
|
border-left: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderMenuDropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
min-width: 11rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderMenuItem {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderMenuItem:hover {
|
||||||
|
background: var(--bg-hover, #e9ecef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasHeaderMenuItem + .canvasHeaderMenuItem {
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasTitle {
|
.canvasTitle {
|
||||||
|
|
@ -265,22 +645,183 @@
|
||||||
|
|
||||||
.canvasArea {
|
.canvasArea {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem;
|
padding: 0;
|
||||||
min-height: 400px;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow-x: visible;
|
||||||
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasDropZone {
|
.canvasDropZone {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
/* Schleifen-Rücklauf: SVG-Pfade dürfen Knotenbox leicht verlassen ohne abzuschneiden */
|
||||||
|
overflow: visible;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
||||||
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
|
background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px);
|
||||||
background-repeat: repeat;
|
background-repeat: repeat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.canvasDropZoneConnectionTool {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNote {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteResize {
|
||||||
|
position: absolute;
|
||||||
|
right: 1px;
|
||||||
|
bottom: 1px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px 0 6px 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
z-index: 3;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 45%,
|
||||||
|
rgba(0, 0, 0, 0.12) 45%,
|
||||||
|
rgba(0, 0, 0, 0.12) 50%,
|
||||||
|
transparent 50%,
|
||||||
|
transparent 58%,
|
||||||
|
rgba(0, 0, 0, 0.18) 58%,
|
||||||
|
rgba(0, 0, 0, 0.18) 64%,
|
||||||
|
transparent 64%
|
||||||
|
);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteResize:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
transparent 0%,
|
||||||
|
transparent 45%,
|
||||||
|
rgba(0, 0, 0, 0.2) 45%,
|
||||||
|
rgba(0, 0, 0, 0.2) 50%,
|
||||||
|
transparent 50%,
|
||||||
|
transparent 58%,
|
||||||
|
rgba(0, 0, 0, 0.26) 58%,
|
||||||
|
rgba(0, 0, 0, 0.26) 64%,
|
||||||
|
transparent 64%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteResize:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color, #007bff);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteSelected {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px var(--primary-color, #007bff),
|
||||||
|
0 1px 4px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteToolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
padding: 0.15rem 0.25rem 0.2rem;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteToolbar:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteGrip {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: -0.12em;
|
||||||
|
color: var(--text-muted, #666);
|
||||||
|
opacity: 0.85;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteSwatches {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteSwatch {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.22);
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteSwatch:hover {
|
||||||
|
filter: brightness(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteSwatchActive {
|
||||||
|
outline: 2px solid var(--primary-color, #007bff);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteBody {
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
cursor: text;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteBody:focus-visible {
|
||||||
|
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteTextarea {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
border-style: solid;
|
||||||
|
border-width: 1px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
resize: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasStickyNoteTextarea:focus {
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
.canvasContent {
|
.canvasContent {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
@ -476,6 +1017,8 @@
|
||||||
|
|
||||||
.handleWrapper:has(.handleOutput) {
|
.handleWrapper:has(.handleOutput) {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
/* Bottom handles: keep circle math aligned with wires even when a label grows row height. */
|
||||||
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.handleWrapper:has(.handleInput) {
|
.handleWrapper:has(.handleInput) {
|
||||||
|
|
@ -507,20 +1050,45 @@
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Node Config Panel */
|
/* Shell: stretches to full canvas-area height so inner `.nodeConfigPanel` can scroll. */
|
||||||
|
.nodeConfigPanelWrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node Config Panel
|
||||||
|
* Fixed-width side panel. The `box-sizing: border-box` + `overflow-x: hidden`
|
||||||
|
* pair acts as a safety net so long unbreakable strings (type names like
|
||||||
|
* `List[ActionDocument]`, hashed IDs, refs like `← node.path → field`) can
|
||||||
|
* never push content out of the panel frame. Children rely on this; e.g.
|
||||||
|
* `RequiredAttributePicker` lays out label/badge so the badge wraps below
|
||||||
|
* a long label rather than escaping to the right.
|
||||||
|
*/
|
||||||
.nodeConfigPanel {
|
.nodeConfigPanel {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||||
width: 280px;
|
width: 280px;
|
||||||
flex-shrink: 0;
|
box-sizing: border-box;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeConfigPanel h4 {
|
.nodeConfigPanel h4 {
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeConfigNameRow {
|
.nodeConfigNameRow {
|
||||||
|
|
@ -547,6 +1115,8 @@
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-secondary, #666);
|
color: var(--text-secondary, #666);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nodeConfigPanel label {
|
.nodeConfigPanel label {
|
||||||
|
|
@ -572,9 +1142,12 @@
|
||||||
min-height: 60px;
|
min-height: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips */
|
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
|
||||||
|
(DataPicker-Dialog wird per createPortal an document.body gehangen — nicht hier). */
|
||||||
.nodeConfigPanel
|
.nodeConfigPanel
|
||||||
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
|
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn):not(
|
||||||
|
[data-accordion-header]
|
||||||
|
):not([data-schedule-day]) {
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
padding: 0.4rem 0.75rem;
|
padding: 0.4rem 0.75rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
|
@ -667,6 +1240,12 @@
|
||||||
background: rgba(220, 53, 69, 0.1);
|
background: rgba(220, 53, 69, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.formFieldOptionsBlock {
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding-top: 0.45rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e8e8e8);
|
||||||
|
}
|
||||||
|
|
||||||
/* Upload node config */
|
/* Upload node config */
|
||||||
.uploadNodeConfig {
|
.uploadNodeConfig {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1257,24 +1836,6 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.canvasGearBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--border-color, #ccc);
|
|
||||||
border-radius: 6px;
|
|
||||||
background: var(--bg-primary, #fff);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvasGearBtn:hover {
|
|
||||||
background: var(--bg-hover, #f0f0f0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.startsInput,
|
.startsInput,
|
||||||
.startsSelect {
|
.startsSelect {
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.5rem;
|
||||||
|
|
@ -1284,53 +1845,112 @@
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Data Picker */
|
/* Data Picker — rendered with createPortal(document.body) so it is not affected
|
||||||
|
by .nodeConfigPanel’s generic CTA `button` styles. */
|
||||||
|
|
||||||
.dataPickerOverlay {
|
.dataPickerOverlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.35);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 11000;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerModal {
|
.dataPickerModal {
|
||||||
background: var(--bg-primary, #fff);
|
background: var(--bg-primary, #fff);
|
||||||
border-radius: 8px;
|
color: var(--text-primary, #1a1a1a);
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
border-radius: 10px;
|
||||||
max-width: 420px;
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
max-height: 80vh;
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
max-width: min(420px, 100vw - 2rem);
|
||||||
|
width: 100%;
|
||||||
|
max-height: min(80vh, 640px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerHeader {
|
.dataPickerHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1rem 1.25rem;
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.15rem;
|
||||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerHeaderControls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerTitle {
|
.dataPickerTitle {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
line-height: 1.35;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerTypeBadge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: ui-monospace, 'Cascadia Code', monospace;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
background: var(--bg-secondary, #f0f0f0);
|
||||||
|
border: 1px solid var(--border-color, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.1rem 0.45rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerStrictLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerClose {
|
.dataPickerClose {
|
||||||
background: none;
|
display: inline-flex;
|
||||||
border: none;
|
align-items: center;
|
||||||
font-size: 1.5rem;
|
justify-content: center;
|
||||||
cursor: pointer;
|
width: 2rem;
|
||||||
color: var(--text-secondary, #666);
|
height: 2rem;
|
||||||
padding: 0 0.25rem;
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-secondary, #f5f5f5);
|
||||||
|
border: 1px solid var(--border-color, #d0d0d0);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.25rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerClose:hover {
|
.dataPickerClose:hover {
|
||||||
color: var(--text-primary, #333);
|
background: var(--bg-hover, #e9ecef);
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
border-color: var(--border-color, #b8b8b8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerBody {
|
.dataPickerBody {
|
||||||
|
|
@ -1345,24 +1965,35 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerNodeSection {
|
.dataPickerNodeSection {
|
||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Expandable source row: neutral “list row”, not a primary CTA. */
|
||||||
.dataPickerNodeHeader {
|
.dataPickerNodeHeader {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0;
|
box-sizing: border-box;
|
||||||
background: none;
|
padding: 0.5rem 0.6rem;
|
||||||
border: none;
|
background: var(--bg-secondary, #f4f5f7);
|
||||||
|
border: 1px solid var(--border-color, #dde1e5);
|
||||||
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.875rem;
|
font-size: 0.85rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
color: var(--text-primary, #1a1a1a);
|
||||||
|
margin: 0;
|
||||||
|
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerNodeHeader:hover {
|
.dataPickerNodeHeader:hover {
|
||||||
background: var(--bg-hover, #f5f5f5);
|
background: var(--bg-hover, #e9ebef);
|
||||||
border-radius: 4px;
|
border-color: var(--border-color, #c8cfd6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerNodeHeader:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color, #4a6fa5);
|
||||||
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dataPickerExpandIcon {
|
.dataPickerExpandIcon {
|
||||||
|
|
@ -1401,6 +2032,105 @@
|
||||||
border-color: var(--primary-color, #007bff);
|
border-color: var(--primary-color, #007bff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hover safety net: every nested span in a leaf inherits the white text so
|
||||||
|
* type-hints and meta info stay readable on the blue hover background. */
|
||||||
|
.dataPickerLeaf:hover * {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline type-hint after a leaf label, e.g. "documents (List[ActionDocument])". */
|
||||||
|
.dataPickerLeafType {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Schema-name hint on the node-section header row. */
|
||||||
|
.dataPickerNodeSchemaHint {
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
|
||||||
|
.dataPickerMismatchBadge {
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--color-warning, #f59e0b);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommended pick: subtle highlight on the row */
|
||||||
|
.dataPickerLeafRecommended {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "Empfohlen" pill shown on recommended entries */
|
||||||
|
.dataPickerRecommendedPill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-left: 5px;
|
||||||
|
background: var(--color-primary-light, #dbeafe);
|
||||||
|
color: var(--color-primary, #2563eb);
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* "iterieren" affordance — visually distinct (subtle accent), readable on
|
||||||
|
* the picker's white background and on the leaf's blue hover background. */
|
||||||
|
.dataPickerIterateBtn {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-secondary, #f5f7fa);
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerIterateBtn:hover {
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
|
||||||
|
.dataPickerCuratedToggle {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
padding: 0.38rem 0.55rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary, #5c6370);
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
border: 1px dashed var(--border-color, #cfd4dc);
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerCuratedToggle:hover {
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
background: var(--bg-secondary, #f4f6f8);
|
||||||
|
border-color: var(--border-color, #b8c0cc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataPickerCuratedDivider {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #8a9199);
|
||||||
|
margin: 0.75rem 0 0.35rem 0;
|
||||||
|
padding-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dynamic Value Field */
|
/* Dynamic Value Field */
|
||||||
.dynamicValueField {
|
.dynamicValueField {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
/**
|
/**
|
||||||
* Automation2FlowEditor
|
* WorkflowFlowEditor
|
||||||
*
|
*
|
||||||
* n8n-style flow builder with backend-driven node list.
|
* n8n-style flow builder with backend-driven node list and categories.
|
||||||
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
|
* Start nodes come from the API (category `start`); invocations are synced on the server from the graph.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
|
@ -22,29 +24,35 @@ import {
|
||||||
archiveVersion,
|
archiveVersion,
|
||||||
createTemplateFromWorkflow,
|
createTemplateFromWorkflow,
|
||||||
copyTemplate,
|
copyTemplate,
|
||||||
|
importWorkflowFromFile,
|
||||||
|
WORKFLOW_FILE_EXTENSION,
|
||||||
type NodeType,
|
type NodeType,
|
||||||
type NodeTypeCategory,
|
type NodeTypeCategory,
|
||||||
type Automation2Graph,
|
type WorkflowGraph,
|
||||||
type Automation2Workflow,
|
type WorkflowDefinition,
|
||||||
type ExecuteGraphResponse,
|
type ExecuteGraphResponse,
|
||||||
type WorkflowEntryPoint,
|
type WorkflowEntryPoint,
|
||||||
type AutoVersion,
|
type AutoVersion,
|
||||||
type AutoTemplateScope,
|
type AutoTemplateScope,
|
||||||
} from '../../../api/workflowApi';
|
} from '../../../api/workflowAutomationApi';
|
||||||
import { FlowCanvas, computeAutoLayout, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
import {
|
||||||
|
FlowCanvas,
|
||||||
|
type CanvasNode,
|
||||||
|
type CanvasConnection,
|
||||||
|
type CanvasStickyNote,
|
||||||
|
type FlowCanvasHandle,
|
||||||
|
type FlowCanvasViewportEditState,
|
||||||
|
} from './FlowCanvas';
|
||||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
import { CanvasHeader } from './CanvasHeader';
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
|
|
||||||
import { TemplatePicker } from './TemplatePicker';
|
import { TemplatePicker } from './TemplatePicker';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
|
import { fromApiGraph, toApiGraph, switchOutputCountFromCases, trimConnectionsForSwitchOutputs } from '../nodes/shared/graphUtils';
|
||||||
import {
|
|
||||||
syncCanvasStartNode,
|
|
||||||
buildInvocationsForPrimaryKind,
|
|
||||||
} from '../nodes/runtime/workflowStartSync';
|
|
||||||
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
import { findGraphErrors } from '../nodes/shared/paramValidation';
|
||||||
|
import { getLabel as getParamLabel } from '../nodes/shared/utils';
|
||||||
|
import { WorkflowDataFlowProvider } from '../context/WorkflowDataFlowContext';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
import { EditorChatPanel } from './EditorChatPanel';
|
import { EditorChatPanel } from './EditorChatPanel';
|
||||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
|
||||||
|
|
@ -52,16 +60,28 @@ import { EditorWorkflowChatList } from './EditorWorkflowChatList';
|
||||||
import { RunTracingPanel } from './RunTracingPanel';
|
import { RunTracingPanel } from './RunTracingPanel';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './WorkflowFlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
|
||||||
|
|
||||||
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
|
const LOG = '[WorkflowEditor]';
|
||||||
buildInvocationsForPrimaryKind('manual', [], runLabel);
|
|
||||||
|
|
||||||
interface Automation2FlowEditorProps {
|
const CANVAS_HISTORY_MAX = 50;
|
||||||
|
|
||||||
|
function cloneCanvasSnapshot(nodes: CanvasNode[], connections: CanvasConnection[]) {
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
...n,
|
||||||
|
parameters: n.parameters ? { ...n.parameters } : {},
|
||||||
|
inputPorts: n.inputPorts?.map((p) => ({ ...p })),
|
||||||
|
outputPorts: n.outputPorts?.map((p) => ({ ...p })),
|
||||||
|
})),
|
||||||
|
connections: connections.map((c) => ({ ...c })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkflowFlowEditorProps {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
mandateId?: string;
|
mandateId?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
|
|
@ -75,7 +95,7 @@ interface Automation2FlowEditorProps {
|
||||||
onSourcesChanged?: () => void;
|
onSourcesChanged?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
|
export const WorkflowFlowEditor: React.FC<WorkflowFlowEditorProps> = ({ instanceId,
|
||||||
mandateId,
|
mandateId,
|
||||||
language = 'de',
|
language = 'de',
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
|
|
@ -93,24 +113,38 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||||
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
|
||||||
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
|
||||||
|
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowAutomationApi').FormFieldType[]>([]);
|
||||||
|
const [conditionOperatorCatalog, setConditionOperatorCatalog] = useState<
|
||||||
|
Record<string, import('../../../api/workflowAutomationApi').ConditionOperatorDef[]>
|
||||||
|
>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
new Set(['start', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
|
||||||
);
|
);
|
||||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||||
|
const flowCanvasRef = useRef<FlowCanvasHandle>(null);
|
||||||
|
const canvasHistoryPastRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
|
||||||
|
const canvasHistoryFutureRef = useRef<Array<{ nodes: CanvasNode[]; connections: CanvasConnection[] }>>([]);
|
||||||
|
const suppressCanvasHistoryRef = useRef(false);
|
||||||
|
const [canvasHistoryTick, setCanvasHistoryTick] = useState(0);
|
||||||
|
const [canvasViewportEdit, setCanvasViewportEdit] = useState<FlowCanvasViewportEditState>({
|
||||||
|
zoom: 1,
|
||||||
|
selectedNodeCount: 0,
|
||||||
|
connectionSelected: false,
|
||||||
|
stickyNoteSelected: false,
|
||||||
|
});
|
||||||
|
const [canvasConnectionToolActive, setCanvasConnectionToolActive] = useState(false);
|
||||||
|
const [canvasStickyNotes, setCanvasStickyNotes] = useState<CanvasStickyNote[]>([]);
|
||||||
const [executing, setExecuting] = useState(false);
|
const [executing, setExecuting] = useState(false);
|
||||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
|
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>([]);
|
||||||
_buildDefaultInvocations(t('Jetzt ausführen'))
|
|
||||||
);
|
|
||||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
|
||||||
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
|
||||||
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
|
||||||
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
|
||||||
|
|
@ -122,10 +156,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
instanceId,
|
instanceId,
|
||||||
mandateId: mandateId || '',
|
mandateId: mandateId || '',
|
||||||
featureInstanceId: instanceId,
|
featureInstanceId: instanceId,
|
||||||
|
surface: 'workflowAutomation',
|
||||||
}), [instanceId, mandateId]);
|
}), [instanceId, mandateId]);
|
||||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||||
const [versionLoading, setVersionLoading] = useState(false);
|
const [versionLoading, setVersionLoading] = useState(false);
|
||||||
|
const didBootstrapEmptyCanvasRef = useRef(false);
|
||||||
|
|
||||||
|
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
|
||||||
|
|
||||||
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
|
||||||
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
|
||||||
|
|
@ -133,6 +171,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||||
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
|
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
|
||||||
});
|
});
|
||||||
|
// Verbose schema toggle: shows the static type-reference block (input/output
|
||||||
|
// schema) and parameter type-badges in NodeConfigPanel. Only the
|
||||||
|
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
|
||||||
|
const [verboseSchema, setVerboseSchema] = useState(() => {
|
||||||
|
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
|
||||||
|
}, [verboseSchema]);
|
||||||
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
|
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -170,7 +217,18 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = 'none';
|
||||||
}, [leftPanelWidth, sidebarWidth]);
|
}, [leftPanelWidth, sidebarWidth]);
|
||||||
|
|
||||||
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
const startNodeTypeIds = useMemo(
|
||||||
|
() => new Set(nodeTypes.filter((n) => n.category === 'start').map((n) => n.id)),
|
||||||
|
[nodeTypes]
|
||||||
|
);
|
||||||
|
const hasCanvasStartNode = useMemo(
|
||||||
|
() => canvasNodes.some((n) => startNodeTypeIds.has(n.type)),
|
||||||
|
[canvasNodes, startNodeTypeIds]
|
||||||
|
);
|
||||||
|
const missingStartNodeBlocking = useMemo(
|
||||||
|
() => canvasNodes.length > 0 && !hasCanvasStartNode,
|
||||||
|
[canvasNodes.length, hasCanvasStartNode]
|
||||||
|
);
|
||||||
|
|
||||||
const nodeOutputsPreview = useMemo(
|
const nodeOutputsPreview = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -178,26 +236,92 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
|
||||||
|
// canvas error badges and the Run-button gate. Graph-level: Save stays
|
||||||
|
// unconditional (Schicht-4 invariant: WIP must always be persistable).
|
||||||
|
const nodeErrors = useMemo(
|
||||||
|
() =>
|
||||||
|
findGraphErrors(
|
||||||
|
canvasNodes,
|
||||||
|
nodeTypes,
|
||||||
|
(p) => getParamLabel(p.description, language) || p.name,
|
||||||
|
),
|
||||||
|
[canvasNodes, nodeTypes, language]
|
||||||
|
);
|
||||||
|
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
|
||||||
|
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
|
||||||
|
|
||||||
|
const pushCanvasHistoryPastFromCurrent = useCallback(() => {
|
||||||
|
if (suppressCanvasHistoryRef.current) return;
|
||||||
|
const snap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
|
||||||
|
const past = canvasHistoryPastRef.current;
|
||||||
|
const last = past[past.length - 1];
|
||||||
|
if (last && JSON.stringify(last) === JSON.stringify(snap)) return;
|
||||||
|
past.push(snap);
|
||||||
|
if (past.length > CANVAS_HISTORY_MAX) past.shift();
|
||||||
|
canvasHistoryFutureRef.current = [];
|
||||||
|
setCanvasHistoryTick((x) => x + 1);
|
||||||
|
}, [canvasNodes, canvasConnections]);
|
||||||
|
|
||||||
|
const onCanvasHistoryCheckpoint = useCallback(() => {
|
||||||
|
pushCanvasHistoryPastFromCurrent();
|
||||||
|
}, [pushCanvasHistoryPastFromCurrent]);
|
||||||
|
|
||||||
|
const undoCanvasEdit = useCallback(() => {
|
||||||
|
const past = canvasHistoryPastRef.current;
|
||||||
|
if (past.length === 0) return;
|
||||||
|
suppressCanvasHistoryRef.current = true;
|
||||||
|
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
|
||||||
|
const restored = past.pop()!;
|
||||||
|
canvasHistoryFutureRef.current.push(currentSnap);
|
||||||
|
setCanvasNodes(restored.nodes);
|
||||||
|
setCanvasConnections(restored.connections);
|
||||||
|
setCanvasHistoryTick((x) => x + 1);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
suppressCanvasHistoryRef.current = false;
|
||||||
|
});
|
||||||
|
flowCanvasRef.current?.focusCanvas();
|
||||||
|
}, [canvasNodes, canvasConnections]);
|
||||||
|
|
||||||
|
const redoCanvasEdit = useCallback(() => {
|
||||||
|
const fut = canvasHistoryFutureRef.current;
|
||||||
|
if (fut.length === 0) return;
|
||||||
|
suppressCanvasHistoryRef.current = true;
|
||||||
|
const currentSnap = cloneCanvasSnapshot(canvasNodes, canvasConnections);
|
||||||
|
const restored = fut.pop()!;
|
||||||
|
canvasHistoryPastRef.current.push(currentSnap);
|
||||||
|
setCanvasNodes(restored.nodes);
|
||||||
|
setCanvasConnections(restored.connections);
|
||||||
|
setCanvasHistoryTick((x) => x + 1);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
suppressCanvasHistoryRef.current = false;
|
||||||
|
});
|
||||||
|
flowCanvasRef.current?.focusCanvas();
|
||||||
|
}, [canvasNodes, canvasConnections]);
|
||||||
|
|
||||||
|
const canCanvasUndo = useMemo(() => canvasHistoryPastRef.current.length > 0, [canvasHistoryTick]);
|
||||||
|
const canCanvasRedo = useMemo(() => canvasHistoryFutureRef.current.length > 0, [canvasHistoryTick]);
|
||||||
|
|
||||||
const applyGraphWithSync = useCallback(
|
const applyGraphWithSync = useCallback(
|
||||||
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
|
(
|
||||||
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
|
graph: WorkflowGraph | null | undefined,
|
||||||
setInvocations(inv);
|
wfInvocations: WorkflowEntryPoint[] | undefined,
|
||||||
if (!graph?.nodes?.length) {
|
opts?: { skipHistory?: boolean }
|
||||||
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
|
) => {
|
||||||
setCanvasNodes(synced.nodes);
|
if (!opts?.skipHistory && !suppressCanvasHistoryRef.current) {
|
||||||
setCanvasConnections(synced.connections);
|
pushCanvasHistoryPastFromCurrent();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
setInvocations(wfInvocations ?? []);
|
||||||
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
|
const g: WorkflowGraph = graph ?? { nodes: [], connections: [] };
|
||||||
setCanvasNodes(synced.nodes);
|
const { nodes, connections } = fromApiGraph(g, nodeTypes);
|
||||||
setCanvasConnections(synced.connections);
|
setCanvasNodes(nodes);
|
||||||
|
setCanvasConnections(connections);
|
||||||
},
|
},
|
||||||
[nodeTypes, language, t]
|
[nodeTypes, pushCanvasHistoryPastFromCurrent]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFromApiGraph = useCallback(
|
const handleFromApiGraph = useCallback(
|
||||||
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
|
(graph: WorkflowGraph, wfInvocations?: WorkflowEntryPoint[]) => {
|
||||||
applyGraphWithSync(graph, wfInvocations);
|
applyGraphWithSync(graph, wfInvocations);
|
||||||
},
|
},
|
||||||
[applyGraphWithSync]
|
[applyGraphWithSync]
|
||||||
|
|
@ -209,11 +333,31 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
|
||||||
|
if (Object.keys(nodeErrors).length > 0) {
|
||||||
|
const firstId = Object.keys(nodeErrors)[0];
|
||||||
|
const firstNode = canvasNodes.find((n) => n.id === firstId);
|
||||||
|
if (firstNode) setSelectedNode(firstNode);
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
|
||||||
|
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (missingStartNodeBlocking) {
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error: t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecuting(true);
|
setExecuting(true);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
||||||
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
|
const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, {
|
||||||
...(ep ? { entryPointId: ep } : {}),
|
...(ep ? { entryPointId: ep } : {}),
|
||||||
});
|
});
|
||||||
setExecuteResult(result);
|
setExecuteResult(result);
|
||||||
|
|
@ -226,7 +370,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setExecuting(false);
|
setExecuting(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors, missingStartNodeBlocking]);
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async () => {
|
||||||
const graph = toApiGraph(canvasNodes, canvasConnections);
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
|
@ -234,11 +378,41 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
|
||||||
|
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
|
||||||
|
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
|
||||||
|
const errorCount = Object.values(nodeErrors).reduce(
|
||||||
|
(acc, list) => acc + list.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const errorNodeCount = Object.keys(nodeErrors).length;
|
||||||
|
const _buildSaveResult = (): ExecuteGraphResponse => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (errorCount > 0) {
|
||||||
|
parts.push(
|
||||||
|
t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
|
||||||
|
.replace('{n}', String(errorCount))
|
||||||
|
.replace('{m}', String(errorNodeCount))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (canvasNodes.length > 0 && !hasCanvasStartNode) {
|
||||||
|
parts.push(t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.'));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
warning: parts.length ? parts.join(' ') : undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
|
const updated = await updateWorkflow(request, currentWorkflowId, {
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
graph,
|
||||||
|
invocations,
|
||||||
|
targetFeatureInstanceId,
|
||||||
|
});
|
||||||
|
setInvocations(updated.invocations ?? []);
|
||||||
|
setExecuteResult(_buildSaveResult());
|
||||||
} else {
|
} else {
|
||||||
const label = await promptInput(t('Workflow-Name:'), {
|
const label = await promptInput(t('Workflow-Name:'), {
|
||||||
title: t('Workflow speichern'),
|
title: t('Workflow speichern'),
|
||||||
|
|
@ -249,40 +423,64 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created = await createWorkflow(request, instanceId, {
|
const created = await createWorkflow(request, {
|
||||||
label: label.trim() || t('Neuer Workflow'),
|
label: label.trim() || t('Neuer Workflow'),
|
||||||
graph,
|
graph,
|
||||||
invocations,
|
invocations,
|
||||||
|
targetFeatureInstanceId,
|
||||||
|
mandateId,
|
||||||
});
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
if (created.invocations?.length) setInvocations(created.invocations);
|
setInvocations(created.invocations ?? []);
|
||||||
setWorkflows((prev) => [...prev, created]);
|
setWorkflows((prev) => [...prev, created]);
|
||||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
setExecuteResult(_buildSaveResult());
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
|
}, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]);
|
||||||
|
|
||||||
const handleLoad = useCallback(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
try {
|
try {
|
||||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
const wf = await fetchWorkflow(request, workflowId);
|
||||||
if (wf.graph) {
|
if (wf.graph) {
|
||||||
handleFromApiGraph(wf.graph, wf.invocations);
|
handleFromApiGraph(wf.graph, wf.invocations);
|
||||||
} else {
|
} else {
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
|
||||||
}
|
}
|
||||||
|
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
|
||||||
|
setWorkflows((prev) => {
|
||||||
|
const idx = prev.findIndex((w) => w.id === workflowId);
|
||||||
|
if (idx === -1) return [...prev, wf];
|
||||||
|
const next = prev.slice();
|
||||||
|
next[idx] = { ...prev[idx], ...wf };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
|
||||||
|
setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
|
||||||
|
setExecuteResult(null);
|
||||||
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
|
try {
|
||||||
|
const result = await fetchWorkflows(request);
|
||||||
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
|
} catch (refreshErr) {
|
||||||
|
console.error(`${LOG} workflows refresh failed`, refreshErr);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setExecuteResult({
|
setExecuteResult({
|
||||||
success: false,
|
success: false,
|
||||||
error: err instanceof Error ? err.message : String(err),
|
error: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
|
[request, handleFromApiGraph, applyGraphWithSync, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkflowSelect = useCallback(
|
const handleWorkflowSelect = useCallback(
|
||||||
|
|
@ -291,7 +489,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
if (workflowId) handleLoad(workflowId);
|
if (workflowId) handleLoad(workflowId);
|
||||||
else {
|
else {
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleLoad, applyGraphWithSync, t]
|
[handleLoad, applyGraphWithSync, t]
|
||||||
|
|
@ -300,36 +498,44 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
const handleNew = useCallback(() => {
|
const handleNew = useCallback(() => {
|
||||||
setCurrentWorkflowId(null);
|
setCurrentWorkflowId(null);
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
}, [applyGraphWithSync, t]);
|
}, [applyGraphWithSync, t]);
|
||||||
|
|
||||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const next = { ...n, parameters };
|
const next = { ...n, parameters };
|
||||||
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
if (n.type === 'flow.switch' && 'cases' in parameters) {
|
||||||
const cases = (parameters.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(parameters.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
|
||||||
setCanvasNodes((prev) =>
|
setCanvasNodes((prev) => {
|
||||||
prev.map((n) => {
|
const nextNodes = prev.map((n) => {
|
||||||
if (n.id !== nodeId) return n;
|
if (n.id !== nodeId) return n;
|
||||||
const merged = { ...(n.parameters ?? {}), ...patch };
|
const merged = { ...(n.parameters ?? {}), ...patch };
|
||||||
const next = { ...n, parameters: merged };
|
const next = { ...n, parameters: merged };
|
||||||
if (n.type === 'flow.switch' && 'cases' in merged) {
|
if (n.type === 'flow.switch' && 'cases' in merged) {
|
||||||
const cases = (merged.cases as unknown[]) ?? [];
|
const newCount = switchOutputCountFromCases(merged.cases);
|
||||||
next.outputs = Math.max(1, cases.length);
|
next.outputs = newCount;
|
||||||
|
setCanvasConnections((conns) =>
|
||||||
|
trimConnectionsForSwitchOutputs(conns, nodeId, n.inputs, newCount)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
})
|
});
|
||||||
);
|
return nextNodes;
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleNodeUpdate = useCallback(
|
const handleNodeUpdate = useCallback(
|
||||||
|
|
@ -341,24 +547,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleApplyWorkflowConfiguration = useCallback(
|
|
||||||
(next: WorkflowEntryPoint[]) => {
|
|
||||||
setInvocations(next);
|
|
||||||
setCanvasNodes((nodes) => {
|
|
||||||
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
|
|
||||||
setCanvasConnections(r.connections);
|
|
||||||
return r.nodes;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[canvasConnections, nodeTypes, language]
|
|
||||||
);
|
|
||||||
|
|
||||||
const loadNodeTypes = useCallback(async () => {
|
const loadNodeTypes = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchNodeTypes(request, instanceId, language);
|
const data = await fetchNodeTypes(request, mandateId || '', language);
|
||||||
setNodeTypes(data.nodeTypes);
|
setNodeTypes(data.nodeTypes);
|
||||||
setCategories(data.categories);
|
setCategories(data.categories);
|
||||||
if (data.portTypeCatalog) {
|
if (data.portTypeCatalog) {
|
||||||
|
|
@ -366,6 +559,8 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setRegistryCatalog(data.portTypeCatalog as never);
|
setRegistryCatalog(data.portTypeCatalog as never);
|
||||||
}
|
}
|
||||||
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
if (data.systemVariables) setSystemVariables(data.systemVariables);
|
||||||
|
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
|
||||||
|
if (data.conditionOperatorCatalog) setConditionOperatorCatalog(data.conditionOperatorCatalog);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
setNodeTypes([]);
|
setNodeTypes([]);
|
||||||
|
|
@ -373,17 +568,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, language, request]);
|
}, [language, request]);
|
||||||
|
|
||||||
const loadWorkflows = useCallback(async () => {
|
const loadWorkflows = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request, { mandateId: mandateId || undefined });
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${LOG} loadWorkflows failed`, e);
|
console.error(`${LOG} loadWorkflows failed`, e);
|
||||||
}
|
}
|
||||||
}, [instanceId, request]);
|
}, [request, mandateId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNodeTypes();
|
loadNodeTypes();
|
||||||
|
|
@ -393,6 +587,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
loadWorkflows();
|
loadWorkflows();
|
||||||
}, [loadWorkflows]);
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCanvasStickyNotes([]);
|
||||||
|
}, [currentWorkflowId]);
|
||||||
|
|
||||||
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
|
||||||
|
|
@ -403,17 +601,34 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || nodeTypes.length === 0) return;
|
if (loading || nodeTypes.length === 0) return;
|
||||||
if (currentWorkflowId || initialWorkflowId) return;
|
if (currentWorkflowId || initialWorkflowId) {
|
||||||
if (canvasNodes.length > 0) return;
|
didBootstrapEmptyCanvasRef.current = false;
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
|
return;
|
||||||
|
}
|
||||||
|
if (didBootstrapEmptyCanvasRef.current) return;
|
||||||
|
didBootstrapEmptyCanvasRef.current = true;
|
||||||
|
if (canvasNodes.length === 0 && canvasConnections.length === 0 && invocations.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.debug(`${LOG} bootstrapping empty canvas`, {
|
||||||
|
currentWorkflowId,
|
||||||
|
initialWorkflowId,
|
||||||
|
canvasNodes: canvasNodes.length,
|
||||||
|
canvasConnections: canvasConnections.length,
|
||||||
|
invocations: invocations.length,
|
||||||
|
});
|
||||||
|
applyGraphWithSync({ nodes: [], connections: [] }, [], {
|
||||||
|
skipHistory: true,
|
||||||
|
});
|
||||||
}, [
|
}, [
|
||||||
loading,
|
loading,
|
||||||
nodeTypes.length,
|
nodeTypes.length,
|
||||||
currentWorkflowId,
|
currentWorkflowId,
|
||||||
initialWorkflowId,
|
initialWorkflowId,
|
||||||
canvasNodes.length,
|
canvasNodes.length,
|
||||||
|
canvasConnections.length,
|
||||||
|
invocations.length,
|
||||||
applyGraphWithSync,
|
applyGraphWithSync,
|
||||||
t,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const toggleCategory = useCallback((id: string) => {
|
const toggleCategory = useCallback((id: string) => {
|
||||||
|
|
@ -427,7 +642,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const handleDropNodeType = useCallback(
|
const handleDropNodeType = useCallback(
|
||||||
(nodeTypeId: string, x: number, y: number) => {
|
(nodeTypeId: string, x: number, y: number) => {
|
||||||
if (nodeTypeId.startsWith('trigger.')) return;
|
|
||||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||||
if (!nt) return;
|
if (!nt) return;
|
||||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
@ -453,17 +667,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadVersions = useCallback(async () => {
|
const loadVersions = useCallback(async () => {
|
||||||
if (!instanceId || !currentWorkflowId) {
|
if (!currentWorkflowId) {
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const v = await fetchVersions(request, instanceId, currentWorkflowId);
|
const v = await fetchVersions(request, currentWorkflowId);
|
||||||
setVersions(v);
|
setVersions(v);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${LOG} loadVersions failed`, e);
|
console.error(`${LOG} loadVersions failed`, e);
|
||||||
}
|
}
|
||||||
}, [instanceId, currentWorkflowId, request]);
|
}, [currentWorkflowId, request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadVersions();
|
loadVersions();
|
||||||
|
|
@ -484,10 +698,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const handlePublishVersion = useCallback(
|
const handlePublishVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await publishVersion(request, instanceId, versionId);
|
await publishVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -495,15 +708,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnpublishVersion = useCallback(
|
const handleUnpublishVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await unpublishVersion(request, instanceId, versionId);
|
await unpublishVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -511,15 +723,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveVersion = useCallback(
|
const handleArchiveVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await archiveVersion(request, instanceId, versionId);
|
await archiveVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -527,14 +738,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateDraft = useCallback(async () => {
|
const handleCreateDraft = useCallback(async () => {
|
||||||
if (!instanceId || !currentWorkflowId) return;
|
if (!currentWorkflowId) return;
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
|
const draft = await createDraftVersion(request, currentWorkflowId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
setCurrentVersionId(draft.id);
|
setCurrentVersionId(draft.id);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -542,16 +753,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
}, [request, currentWorkflowId, loadVersions]);
|
||||||
|
|
||||||
// Template: save current workflow as template
|
// Template: save current workflow as template
|
||||||
const [templateSaving, setTemplateSaving] = useState(false);
|
const [templateSaving, setTemplateSaving] = useState(false);
|
||||||
const handleSaveAsTemplate = useCallback(
|
const handleSaveAsTemplate = useCallback(
|
||||||
async (scope: AutoTemplateScope) => {
|
async (scope: AutoTemplateScope) => {
|
||||||
if (!instanceId || !currentWorkflowId) return;
|
if (!currentWorkflowId) return;
|
||||||
setTemplateSaving(true);
|
setTemplateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
|
await createTemplateFromWorkflow(request, currentWorkflowId, scope);
|
||||||
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -559,16 +770,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setTemplateSaving(false);
|
setTemplateSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, currentWorkflowId]
|
[request, currentWorkflowId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Template: new workflow from template
|
// Template: new workflow from template
|
||||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||||
const handleNewFromTemplate = useCallback(
|
const handleNewFromTemplate = useCallback(
|
||||||
async (templateId: string) => {
|
async (templateId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
try {
|
||||||
const wf = await copyTemplate(request, instanceId, templateId);
|
const wf = await copyTemplate(request, templateId);
|
||||||
setWorkflows((prev) => [...prev, wf]);
|
setWorkflows((prev) => [...prev, wf]);
|
||||||
setCurrentWorkflowId(wf.id);
|
setCurrentWorkflowId(wf.id);
|
||||||
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
||||||
|
|
@ -577,21 +787,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, handleFromApiGraph]
|
[request, handleFromApiGraph]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
|
|
||||||
try {
|
|
||||||
await updateWorkflow(request, instanceId, workflowId, { label: newName });
|
|
||||||
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
|
|
||||||
} catch (e: unknown) {
|
|
||||||
console.error(`${LOG} rename failed`, e);
|
|
||||||
}
|
|
||||||
}, [request, instanceId]);
|
|
||||||
|
|
||||||
const handleAutoLayout = useCallback(() => {
|
|
||||||
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
|
|
||||||
}, [canvasConnections]);
|
|
||||||
|
|
||||||
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
|
||||||
|
|
||||||
|
|
@ -633,7 +834,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
expandedCategories={expandedCategories}
|
expandedCategories={expandedCategories}
|
||||||
onToggleCategory={toggleCategory}
|
onToggleCategory={toggleCategory}
|
||||||
excludedCategories={sidebarExcludedCategories}
|
|
||||||
style={_sidebarStyle}
|
style={_sidebarStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -641,15 +841,61 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const configurableSelected =
|
const configurableSelected =
|
||||||
selectedNode &&
|
selectedNode &&
|
||||||
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
|
[
|
||||||
selectedNode.type.startsWith(p)
|
'input.',
|
||||||
);
|
'ai.',
|
||||||
|
'email.',
|
||||||
|
'sharepoint.',
|
||||||
|
'clickup.',
|
||||||
|
'trigger.',
|
||||||
|
'flow.',
|
||||||
|
'file.',
|
||||||
|
'trustee.',
|
||||||
|
'context.',
|
||||||
|
'data.',
|
||||||
|
'redmine.',
|
||||||
|
].some((p) => selectedNode.type.startsWith(p));
|
||||||
|
|
||||||
|
const canvasHeaderEdit = useMemo(
|
||||||
|
() => ({
|
||||||
|
zoomPercent: Math.round(canvasViewportEdit.zoom * 100),
|
||||||
|
selectedNodeCount: canvasViewportEdit.selectedNodeCount,
|
||||||
|
connectionSelected: canvasViewportEdit.connectionSelected,
|
||||||
|
stickyNoteSelected: canvasViewportEdit.stickyNoteSelected,
|
||||||
|
connectionToolActive: canvasConnectionToolActive,
|
||||||
|
canUndo: canCanvasUndo,
|
||||||
|
canRedo: canCanvasRedo,
|
||||||
|
onZoomIn: () => flowCanvasRef.current?.zoomIn(),
|
||||||
|
onZoomOut: () => flowCanvasRef.current?.zoomOut(),
|
||||||
|
onZoomPercentCommit: (pct: number) => flowCanvasRef.current?.setZoomPercent(pct),
|
||||||
|
onFitWindow: () => flowCanvasRef.current?.fitWindow(),
|
||||||
|
onResetView: () => flowCanvasRef.current?.resetView(),
|
||||||
|
onUndo: undoCanvasEdit,
|
||||||
|
onRedo: redoCanvasEdit,
|
||||||
|
onDeleteSelection: () => flowCanvasRef.current?.deleteSelection(),
|
||||||
|
onDuplicateNode: () => flowCanvasRef.current?.duplicateSingleSelection(),
|
||||||
|
onToggleConnectionTool: () => flowCanvasRef.current?.toggleConnectionTool(),
|
||||||
|
onArrangeNodes: () => flowCanvasRef.current?.arrangeNodes(),
|
||||||
|
onAddCanvasComment: () => flowCanvasRef.current?.addCanvasComment(),
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
canvasViewportEdit,
|
||||||
|
canvasConnectionToolActive,
|
||||||
|
canCanvasUndo,
|
||||||
|
canCanvasRedo,
|
||||||
|
undoCanvasEdit,
|
||||||
|
redoCanvasEdit,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
|
||||||
{leftPanelOpen && (<>
|
{leftPanelOpen && (<>
|
||||||
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
|
<div
|
||||||
|
data-suppress-flow-node-hotkeys=""
|
||||||
|
style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}
|
||||||
|
>
|
||||||
<div className={styles.rightTabBar}>
|
<div className={styles.rightTabBar}>
|
||||||
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
{(['ai', 'chats', 'files', 'sources'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -699,7 +945,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
activeTab={udbTab as UdbTab}
|
activeTab={udbTab as UdbTab}
|
||||||
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
||||||
hideTabs={['chats']}
|
hideTabs={['chats']}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={async (fileId, fileName) => {
|
||||||
|
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
|
||||||
|
try {
|
||||||
|
const result = await importWorkflowFromFile(request, { fileId });
|
||||||
|
await loadWorkflows();
|
||||||
|
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[workflowAutomation] workflow file import failed', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onFileSelect?.(fileId, fileName);
|
||||||
|
}}
|
||||||
onSourcesChanged={onSourcesChanged}
|
onSourcesChanged={onSourcesChanged}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -717,11 +975,24 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onNew={handleNew}
|
onNew={handleNew}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
onToggleWorkspacePanel={() => setLeftPanelOpen((prev) => !prev)}
|
||||||
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
|
workspacePanelOpen={leftPanelOpen}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
executeBlockedReason={
|
||||||
|
hasGraphErrors
|
||||||
|
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
|
||||||
|
: missingStartNodeBlocking
|
||||||
|
? t('Ohne Start-Node kann der Workflow nicht ausgeführt werden.')
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onExecuteBlockedClick={() => {
|
||||||
|
if (firstErrorNodeId) {
|
||||||
|
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
|
||||||
|
if (n) setSelectedNode(n);
|
||||||
|
}
|
||||||
|
}}
|
||||||
executeResult={executeResult}
|
executeResult={executeResult}
|
||||||
versions={versions}
|
versions={versions}
|
||||||
currentVersionId={currentVersionId}
|
currentVersionId={currentVersionId}
|
||||||
|
|
@ -734,12 +1005,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onSaveAsTemplate={handleSaveAsTemplate}
|
onSaveAsTemplate={handleSaveAsTemplate}
|
||||||
templateSaving={templateSaving}
|
templateSaving={templateSaving}
|
||||||
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
onNewFromTemplate={() => setTemplatePickerOpen(true)}
|
||||||
onWorkflowRename={handleWorkflowRename}
|
verboseSchema={verboseSchema}
|
||||||
onAutoLayout={handleAutoLayout}
|
onVerboseSchemaChange={setVerboseSchema}
|
||||||
|
canvasEdit={canvasHeaderEdit}
|
||||||
/>
|
/>
|
||||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0, alignItems: 'stretch' }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0, minHeight: 0 }}>
|
||||||
<FlowCanvas
|
<FlowCanvas
|
||||||
|
ref={flowCanvasRef}
|
||||||
nodes={canvasNodes}
|
nodes={canvasNodes}
|
||||||
connections={canvasConnections}
|
connections={canvasConnections}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
|
|
@ -750,10 +1023,32 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
getCategoryIcon={getCategoryIcon}
|
getCategoryIcon={getCategoryIcon}
|
||||||
onSelectionChange={setSelectedNode}
|
onSelectionChange={setSelectedNode}
|
||||||
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
|
||||||
|
nodeErrors={nodeErrors}
|
||||||
|
onViewportEditState={setCanvasViewportEdit}
|
||||||
|
onHistoryCheckpoint={onCanvasHistoryCheckpoint}
|
||||||
|
onConnectionToolActiveChange={setCanvasConnectionToolActive}
|
||||||
|
stickyNotes={canvasStickyNotes}
|
||||||
|
onStickyNotesChange={setCanvasStickyNotes}
|
||||||
|
onExternalDrop={async (mime, payload) => {
|
||||||
|
if (mime !== 'application/json+workflow') return false;
|
||||||
|
const p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||||
|
const fileId = p?.files?.[0]?.id;
|
||||||
|
if (!fileId) return false;
|
||||||
|
try {
|
||||||
|
const result = await importWorkflowFromFile(request, { fileId });
|
||||||
|
await loadWorkflows();
|
||||||
|
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`${LOG} workflow drop import failed`, e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{configurableSelected && selectedNode && (
|
{configurableSelected && selectedNode && (
|
||||||
<Automation2DataFlowProvider
|
<div className={styles.nodeConfigPanelWrap} data-suppress-flow-node-hotkeys="">
|
||||||
|
<WorkflowDataFlowProvider
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
nodes={canvasNodes}
|
nodes={canvasNodes}
|
||||||
connections={canvasConnections}
|
connections={canvasConnections}
|
||||||
|
|
@ -762,6 +1057,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
language={language}
|
language={language}
|
||||||
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
portTypeCatalog={portTypeCatalog as Record<string, never>}
|
||||||
systemVariables={systemVariables as Record<string, never>}
|
systemVariables={systemVariables as Record<string, never>}
|
||||||
|
formFieldTypes={formFieldTypes}
|
||||||
|
conditionOperatorCatalog={conditionOperatorCatalog}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
>
|
>
|
||||||
<NodeConfigPanel
|
<NodeConfigPanel
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
|
|
@ -772,15 +1071,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
onNodeUpdate={handleNodeUpdate}
|
onNodeUpdate={handleNodeUpdate}
|
||||||
instanceId={instanceId}
|
instanceId={instanceId}
|
||||||
request={request}
|
request={request}
|
||||||
|
verboseSchema={verboseSchema}
|
||||||
/>
|
/>
|
||||||
</Automation2DataFlowProvider>
|
</WorkflowDataFlowProvider>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right panel: Nodes + Tracing tabs */}
|
{/* Right panel: Nodes + Tracing tabs */}
|
||||||
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
|
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
|
||||||
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
|
<div
|
||||||
|
data-suppress-flow-node-hotkeys=""
|
||||||
|
style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}
|
||||||
|
>
|
||||||
<div className={styles.rightTabBar}>
|
<div className={styles.rightTabBar}>
|
||||||
<button
|
<button
|
||||||
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
|
||||||
|
|
@ -813,12 +1117,6 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PromptDialog />
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
|
||||||
open={workflowSettingsOpen}
|
|
||||||
onClose={() => setWorkflowSettingsOpen(false)}
|
|
||||||
invocations={invocations}
|
|
||||||
onApply={handleApplyWorkflowConfiguration}
|
|
||||||
/>
|
|
||||||
<TemplatePicker
|
<TemplatePicker
|
||||||
open={templatePickerOpen}
|
open={templatePickerOpen}
|
||||||
onClose={() => setTemplatePickerOpen(false)}
|
onClose={() => setTemplatePickerOpen(false)}
|
||||||
|
|
@ -830,4 +1128,4 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Automation2FlowEditor;
|
export default WorkflowFlowEditor;
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
// Copyright (c) 2026 PowerOn AG
|
||||||
|
// All rights reserved.
|
||||||
|
export { WorkflowFlowEditor, WorkflowFlowEditor as FlowEditor } from './editor/WorkflowFlowEditor';
|
||||||
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
|
||||||
export { FlowCanvas } from './editor/FlowCanvas';
|
export { FlowCanvas, STICKY_NOTE_PALETTE, STICKY_NOTE_DEFAULT_COLOR_ID, STICKY_NOTE_DEFAULT_HEIGHT, getStickyNotePaletteEntry } from './editor/FlowCanvas';
|
||||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection, CanvasStickyNote, FlowCanvasHandle, FlowCanvasViewportEditState } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
export { NodeSidebar } from './editor/NodeSidebar';
|
export { NodeSidebar } from './editor/NodeSidebar';
|
||||||
export { NodeListItem } from './editor/NodeListItem';
|
export { NodeListItem } from './editor/NodeListItem';
|
||||||
export { CanvasHeader } from './editor/CanvasHeader';
|
export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
|
export type { CanvasHeaderCanvasEditProps } from './editor/CanvasHeader';
|
||||||
export * from './nodes/shared/utils';
|
export * from './nodes/shared/utils';
|
||||||
export * from './nodes/shared/constants';
|
export * from './nodes/shared/constants';
|
||||||
export * from './nodes/shared/graphUtils';
|
export * from './nodes/shared/graphUtils';
|
||||||
|
|
|
||||||
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
|
* Form node config - draggable fields, types, required toggle
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/WorkflowFlowEditor.module.css';
|
||||||
|
import { useWorkflowDataFlow } from '../../context/WorkflowDataFlowContext';
|
||||||
|
import { FormFieldOptionsEditor } from './FormFieldOptionsEditor';
|
||||||
|
import {
|
||||||
|
deriveFormFieldPayloadKey,
|
||||||
|
formFieldTypeHasConfigurableOptions,
|
||||||
|
normalizeFormFieldOptions,
|
||||||
|
} from './formFieldOptionsUtils';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
updateParam,
|
|
||||||
instanceId,
|
|
||||||
request,
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const ctx = useWorkflowDataFlow();
|
||||||
|
const fieldTypeOptions = ctx?.formFieldTypes?.length
|
||||||
|
? ctx.formFieldTypes
|
||||||
|
: FORM_FIELD_TYPES.map((ft) => ({ id: ft, label: FORM_FIELD_TYPE_LABELS[ft] ?? ft, portType: 'str' }));
|
||||||
const fields = (params.fields as FormField[]) ?? [];
|
const fields = (params.fields as FormField[]) ?? [];
|
||||||
const [connections, setConnections] = useState<UserConnection[]>([]);
|
|
||||||
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!instanceId || !request) {
|
|
||||||
setConnections([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
setConnectionsLoading(true);
|
|
||||||
fetchConnections(request, instanceId)
|
|
||||||
.then((rows) => {
|
|
||||||
if (!cancelled) setConnections(rows.filter((c) => c.authority === 'clickup'));
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setConnections([]);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setConnectionsLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [instanceId, request]);
|
|
||||||
|
|
||||||
const moveField = (fromIndex: number, toIndex: number) => {
|
const moveField = (fromIndex: number, toIndex: number) => {
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
||||||
|
|
@ -87,20 +72,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
</span>
|
</span>
|
||||||
<div className={styles.formFieldInputs}>
|
<div className={styles.formFieldInputs}>
|
||||||
<input
|
<input
|
||||||
placeholder={t('name')}
|
placeholder={t('Bezeichnung')}
|
||||||
value={f.name ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...fields];
|
|
||||||
next[i] = { ...next[i], name: e.target.value };
|
|
||||||
updateParam('fields', next);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
placeholder={t('label')}
|
|
||||||
value={f.label ?? ''}
|
value={f.label ?? ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
const label = e.target.value;
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
next[i] = { ...next[i], label: e.target.value };
|
next[i] = { ...next[i], label, name: deriveFormFieldPayloadKey(label, i) };
|
||||||
updateParam('fields', next);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -108,33 +85,22 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.formFieldRowFooter}>
|
<div className={styles.formFieldRowFooter}>
|
||||||
<select
|
<select
|
||||||
value={f.type ?? 'string'}
|
value={f.type ?? 'text'}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = [...fields];
|
const next = [...fields];
|
||||||
const fieldType = e.target.value;
|
const type = e.target.value as FormField['type'];
|
||||||
next[i] = {
|
const row: FormField = { ...f, type };
|
||||||
...next[i],
|
if (formFieldTypeHasConfigurableOptions(type)) {
|
||||||
type: fieldType,
|
row.options = normalizeFormFieldOptions(row.options);
|
||||||
...(fieldType === 'clickup_tasks'
|
}
|
||||||
? { clickupStatusOptions: undefined }
|
next[i] = row;
|
||||||
: fieldType === 'clickup_status'
|
|
||||||
? { clickupConnectionId: undefined, clickupListId: undefined }
|
|
||||||
: {
|
|
||||||
clickupConnectionId: undefined,
|
|
||||||
clickupListId: undefined,
|
|
||||||
clickupStatusOptions: undefined,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
updateParam('fields', next);
|
updateParam('fields', next);
|
||||||
}}
|
}}
|
||||||
style={{ width: 'auto', minWidth: 90 }}
|
style={{ width: 'auto', minWidth: 90 }}
|
||||||
>
|
>
|
||||||
<option value="string">{t('Text')}</option>
|
{fieldTypeOptions.map((ft) => (
|
||||||
<option value="number">{t('Zahl')}</option>
|
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
|
||||||
<option value="date">{t('Datum')}</option>
|
))}
|
||||||
<option value="boolean">{t('Kontrollkästchen')}</option>
|
|
||||||
<option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
|
|
||||||
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
|
|
||||||
</select>
|
</select>
|
||||||
<label className={styles.formFieldRequiredLabel}>
|
<label className={styles.formFieldRequiredLabel}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -157,72 +123,31 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
|
||||||
<FaTimes />
|
<FaTimes />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{f.type === 'clickup_status' ? (
|
{formFieldTypeHasConfigurableOptions(f.type) ? (
|
||||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
|
<FormFieldOptionsEditor
|
||||||
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
|
className={styles.formFieldOptionsBlock}
|
||||||
<p style={{ margin: '0 0 6px' }}>
|
options={normalizeFormFieldOptions(f.options)}
|
||||||
{t(
|
onChange={(opts) => {
|
||||||
'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
|
const next = [...fields];
|
||||||
{ count: String(f.clickupStatusOptions.length) }
|
next[i] = { ...next[i], options: opts };
|
||||||
)}
|
updateParam('fields', next);
|
||||||
</p>
|
}}
|
||||||
) : (
|
/>
|
||||||
<p style={{ margin: '0 0 6px' }}>
|
|
||||||
{t(
|
|
||||||
'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{f.type === 'clickup_tasks' ? (
|
|
||||||
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
|
||||||
{t('ClickUp-Verbindung')}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={f.clickupConnectionId ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...fields];
|
|
||||||
next[i] = { ...next[i], clickupConnectionId: e.target.value };
|
|
||||||
updateParam('fields', next);
|
|
||||||
}}
|
|
||||||
disabled={connectionsLoading || !instanceId}
|
|
||||||
style={{ width: '100%', marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
<option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
|
|
||||||
{connections.map((c) => (
|
|
||||||
<option key={c.id} value={c.id}>
|
|
||||||
{c.externalUsername ?? c.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
|
|
||||||
{t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
|
|
||||||
value={f.clickupListId ?? ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const next = [...fields];
|
|
||||||
next[i] = { ...next[i], clickupListId: e.target.value };
|
|
||||||
updateParam('fields', next);
|
|
||||||
}}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
|
|
||||||
{t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
|
|
||||||
<code>{'{ add: [taskId], rem: [] }'}</code>{' '}
|
|
||||||
{t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
updateParam('fields', [
|
||||||
|
...fields,
|
||||||
|
{
|
||||||
|
name: deriveFormFieldPayloadKey('', fields.length),
|
||||||
|
type: 'text',
|
||||||
|
label: '',
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
+ {t('Feld')}
|
+ {t('Feld')}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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