Compare commits

..

74 commits

Author SHA1 Message Date
Ida
66a7a6fa56 neue context nodes hinzugefügt, muss noch debuggt werden 2026-05-12 06:34:32 +02:00
Ida
294803e66c node handover standartisiert, kein hardcoden mehr, inhalt extraktion node verbessert, output ports vereinheitlicht mit user im blick 2026-05-12 06:34:32 +02:00
Ida
ae630201ba finished file tree folder selection in file create node 2026-05-12 06:34:30 +02:00
Ida
7fb96451a5 workign on folder location in file create node 2026-05-12 06:34:10 +02:00
Patrick Motsch
618574bc76
Merge pull request #79 from valueonag/feat/demo-system-readieness
abo enterprise, ai agent fixes
2026-05-10 22:21:09 +02:00
ValueOn AG
98e7679464 abo enterprise, ai agent fixes 2026-05-10 22:09:55 +02:00
Patrick Motsch
3477126d9f
Merge pull request #78 from valueonag/feat/demo-system-readieness
dns switch poweron-center to poweron.swiss
2026-05-08 13:22:30 +02:00
ValueOn AG
f5a5793309 dns switch poweron-center to poweron.swiss 2026-05-08 11:49:24 +02:00
Patrick Motsch
77f4693f4b
Merge pull request #77 from valueonag/feat/demo-system-readieness
merge fixes
2026-05-08 00:43:43 +02:00
ValueOn AG
5c8c80872a merge fixes 2026-05-08 00:42:27 +02:00
Patrick Motsch
a96295490f
Merge pull request #76 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-08 00:29:01 +02:00
Patrick Motsch
bb9fd56bc6
Merge branch 'int' into feat/demo-system-readieness 2026-05-08 00:28:53 +02:00
ValueOn AG
5eb9253fb1 build fixes 2026-05-08 00:15:26 +02:00
ValueOn AG
a912db3fee refactored comcoach und teamsbot 2026-05-06 23:28:15 +02:00
Ida
877b4fec7e fix: when moving folders the targetParentId was always missing, resulting in the folder not being moved 2026-05-06 08:40:33 +02:00
Ida
25b56f585e fix: readded new folder button to folder tree component 2026-05-06 08:19:37 +02:00
Ida
930a34662d fix: falsche gruppierung entfernt, gruppierung richtig implementiert 2026-05-04 17:29:11 +02:00
ValueOn AG
d42fa02736 fix import 2026-05-04 09:33:14 +02:00
ValueOn AG
dca587a2df fixed ux for expand object scrolling 2026-05-04 09:33:14 +02:00
ValueOn AG
79557e51ed fixed component formgeneratortree and truastee workflows 2026-05-04 09:33:14 +02:00
Patrick Motsch
2739b145db
Merge pull request #72 from valueonag/int
Int
2026-05-03 22:47:27 +02:00
Patrick Motsch
73d03b364b
Merge pull request #71 from valueonag/feat/demo-system-readieness
fix import
2026-05-03 22:25:48 +02:00
ValueOn AG
1219d616d4 fix import 2026-05-03 22:24:47 +02:00
Patrick Motsch
1741f497ec
Merge pull request #69 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-03 22:20:45 +02:00
ValueOn AG
0055b8ce44 fixed ux for expand object scrolling 2026-05-03 22:19:20 +02:00
ValueOn AG
d11280fe34 fixed component formgeneratortree and truastee workflows 2026-05-03 22:03:42 +02:00
Ida
3d580a5fca ValueOn Lead to Opportunity durchgespielt, bugfixes im datapicker und node hadover 2026-05-03 18:02:44 +02:00
Ida
1d2d247273 fix: alle Node definitionen korrigiert und im backend gesetzt - keine mapping layer sonder saubere quelldaten, fehlende dataRef parameter hinzugefügt, damit jede node kontext nutzen kann 2026-05-03 15:07:25 +02:00
Patrick Motsch
f6fa57180e
Merge pull request #68 from valueonag/int
Int
2026-05-01 00:01:55 +02:00
Patrick Motsch
992c0472c6
Merge pull request #67 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-05-01 00:00:30 +02:00
ValueOn AG
02bf4020d7 build fixes 2026-05-01 00:00:06 +02:00
ValueOn AG
c7e94aea79 fixed nodes handovers 2026-04-30 23:54:51 +02:00
Ida
7c05cb0dd7 replaced file tree mit formgenerator gruppierung 2026-04-30 12:40:43 +02:00
Ida
e7a79a3484 UI Verbesserungen Gruppierung und Anwendung auf alle Seiten 2026-04-30 10:46:44 +02:00
ValueOn AG
ad96c6d861 fixes 2026-04-29 23:13:01 +02:00
ValueOn AG
70459d57e3 fixes before document generation refactory styles 2026-04-29 22:54:26 +02:00
ValueOn AG
8cecf3b320 plana+c implemented 2026-04-29 21:27:15 +02:00
Ida
aff9dcb7bd build errors 2026-04-29 18:35:42 +02:00
Ida
31586d62c1 build errors 2026-04-29 18:32:50 +02:00
Ida
c8e9304801 gruppierung fertig gestellt formgenerator 2026-04-29 18:25:42 +02:00
Ida
b61544d8b1 feat: rag extension frontend consent 2026-04-29 14:53:38 +02:00
Patrick Motsch
26958d1e16
Merge pull request #65 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-29 01:56:07 +02:00
ValueOn AG
28951a7d22 fixes infomaniac different than in doc 2026-04-29 00:57:24 +02:00
ValueOn AG
9e08953c44 kdrive fix 2026-04-29 00:35:11 +02:00
Patrick Motsch
a0c2323fe6
Merge pull request #63 from valueonag/feat/demo-system-readieness
fix build
2026-04-27 07:25:50 +02:00
ValueOn AG
34d6c2b83d fix build 2026-04-27 07:25:17 +02:00
Patrick Motsch
3f80d6d434
Merge pull request #62 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-27 00:00:49 +02:00
ValueOn AG
3016806db9 added infomaniak 2026-04-26 23:59:14 +02:00
Patrick Motsch
974c48e24d
Merge pull request #61 from valueonag/int
Int
2026-04-26 23:16:01 +02:00
Patrick Motsch
fe857d5ade
Merge pull request #59 from valueonag/feat/demo-system-readieness
build fix
2026-04-26 23:06:47 +02:00
ValueOn AG
a9e8e8cddd build fix 2026-04-26 23:05:15 +02:00
Patrick Motsch
2994f3a090
Merge pull request #58 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-26 22:54:40 +02:00
ValueOn AG
f0e73b62d2 Graph and data class falignment strict 2026-04-26 22:53:39 +02:00
ValueOn AG
8679cdffcb datamodel sctirc fk logic in one place 2026-04-26 18:11:52 +02:00
ValueOn AG
d8ff3a84d9 fixed user references 2026-04-26 08:57:47 +02:00
ValueOn AG
c47dc67a84 cleanup internal marked exports 2026-04-26 08:31:31 +02:00
ValueOn AG
e09ed758ff teamsbot 2026-04-25 01:13:13 +02:00
ValueOn AG
fc2cce8732 fixes 2026-04-23 23:09:54 +02:00
Patrick Motsch
0270f59d44
Merge pull request #56 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-22 00:00:08 +02:00
ValueOn AG
208f7b63df ui build fix 2026-04-21 23:56:19 +02:00
ValueOn AG
1c2a196192 fixes udb, outlook, workflow 2026-04-21 23:49:50 +02:00
ValueOn AG
c702740714 redmine integrated and fixed 2026-04-21 21:30:15 +02:00
ValueOn AG
0bdaf86153 redmine integration 2026-04-21 18:14:26 +02:00
Patrick Motsch
ebaaef7b4e
Merge pull request #54 from valueonag/feat/demo-system-readieness
fix critical trustee db sync
2026-04-21 10:46:22 +02:00
ValueOn AG
d771d4bc09 fix critical trustee db sync 2026-04-21 10:45:11 +02:00
Patrick Motsch
9093827e7c
Merge pull request #53 from valueonag/feat/demo-system-readieness
udb fix
2026-04-21 08:58:15 +02:00
ValueOn AG
1c4233c7ea udb fix 2026-04-21 08:57:49 +02:00
Patrick Motsch
45ea3ed48b
Merge pull request #51 from valueonag/feat/demo-system-readieness
fixes
2026-04-21 07:46:58 +02:00
ValueOn AG
8e5a01df6d fixes 2026-04-21 07:46:18 +02:00
Patrick Motsch
3f4a98381d
Merge pull request #49 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-21 00:55:25 +02:00
ValueOn AG
b4574b6a2e fixes 2026-04-21 00:54:57 +02:00
ValueOn AG
46d6ad1dfa Merge branch 'feat/demo-system-readieness' of https://github.com/valueonag/frontend_nyla into feat/demo-system-readieness 2026-04-21 00:50:46 +02:00
ValueOn AG
7d84160cdb data source fixes 2026-04-21 00:50:42 +02:00
Patrick Motsch
629d26c404
Merge pull request #48 from valueonag/int
Int
2026-04-20 19:12:44 +02:00
219 changed files with 31446 additions and 7136 deletions

View file

@ -1,27 +0,0 @@
name: Deploy Nyla
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy to Infomaniak VM
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
ssh -i ~/.ssh/deploy_key ubuntu@porta.poweron.swiss "
cd /srv/nyla/current &&
git pull &&
npm ci &&
npm run build:prod &&
sudo systemctl reload nginx
"

View file

@ -27,7 +27,7 @@ jobs:
- name: Copy integration environment file
run: |
cp config/.env.int .env
cp config/env-poweron-nyla-int.env .env
- name: Install dependencies
run: |

View file

@ -27,7 +27,7 @@ jobs:
- name: Copy production environment file
run: |
cp config/.env.prod .env
cp config/env-poweron-nyla-prod.env .env
- name: Install dependencies
run: |

6
.gitignore vendored
View file

@ -30,7 +30,5 @@ dist-ssr
.cursorignore
# Keep environment template files in config/
!config/.env.dev
!config/.env.int
!config/.env.prod
# Keep environment files in config/ (naming: env-<workflow>.env)
!config/env-*.env

View file

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

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

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

13
env.d.ts vendored
View file

@ -1,15 +1,6 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_MICROSOFT_CLIENT_ID: string
readonly VITE_MICROSOFT_TENANT_ID: string
readonly VITE_ENTRA_CLIENT_SECRET: string
readonly VITE_ENTRA_AUTHORITY: string
readonly VITE_ENTRA_REDIRECT_PATH: string
readonly VITE_ENTRA_REDIRECT_URI: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly VITE_API_BASE_URL?: string
readonly VITE_APP_NAME?: string
}

View file

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

1654
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

@ -25,7 +25,6 @@ import Reset from './pages/Reset';
import { InvitePage } from './pages/InvitePage';
// Providers
import { AuthProvider } from './providers/auth/AuthProvider';
import { ProtectedRoute } from './providers/auth/ProtectedRoute';
import { LanguageProvider } from './providers/language/LanguageContext';
import { ToastProvider } from './contexts/ToastContext';
@ -71,7 +70,6 @@ function App() {
return (
<LanguageProvider>
<AuthProvider>
<ToastProvider>
<VoiceCatalogProvider>
<WorkflowSelectionProvider>
@ -147,8 +145,7 @@ function App() {
<Route path="dashboard" element={<FeatureViewPage view="dashboard" />} />
<Route path="organisations" element={<FeatureViewPage view="organisations" />} />
<Route path="contracts" element={<FeatureViewPage view="contracts" />} />
<Route path="documents" element={<FeatureViewPage view="documents" />} />
<Route path="positions" element={<FeatureViewPage view="positions" />} />
<Route path="data-tables" element={<FeatureViewPage view="data-tables" />} />
<Route path="roles" element={<FeatureViewPage view="roles" />} />
<Route path="access" element={<FeatureViewPage view="access" />} />
<Route path="runs" element={<FeatureViewPage view="runs" />} />
@ -158,8 +155,7 @@ function App() {
<Route path="chat" element={<FeatureViewPage view="chat" />} />
<Route path="threads" element={<FeatureViewPage view="threads" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="import-process" element={<FeatureViewPage view="import-process" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
@ -181,12 +177,19 @@ function App() {
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
<Route path="settings" element={<FeatureViewPage view="settings" />} />
{/* Shared: assistant + modules routes (ComCoach + TeamsBot) */}
<Route path="assistant" element={<FeatureViewPage view="assistant" />} />
<Route path="modules" element={<FeatureViewPage view="modules" />} />
{/* Neutralization Feature Views */}
<Route path="playground" element={<FeatureViewPage view="playground" />} />
{/* CommCoach Feature Views */}
<Route path="coaching" element={<FeatureViewPage view="coaching" />} />
<Route path="dossier" element={<FeatureViewPage view="dossier" />} />
<Route path="session" element={<FeatureViewPage view="session" />} />
{/* Redmine Feature Views */}
<Route path="stats" element={<FeatureViewPage view="stats" />} />
<Route path="browser" element={<FeatureViewPage view="browser" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} />
@ -235,7 +238,6 @@ function App() {
</WorkflowSelectionProvider>
</VoiceCatalogProvider>
</ToastProvider>
</AuthProvider>
</LanguageProvider>
);
}

View file

@ -46,7 +46,14 @@ import { getApiBaseUrl } from '../config/config';
const api = axios.create({
baseURL: getApiBaseUrl(),
withCredentials: true
withCredentials: true,
// FastAPI expects repeat-style array query params (``?ids=1&ids=2``).
// Axios v1.x default would render ``?ids[]=1&ids[]=2``, which FastAPI
// silently drops -- e.g. ``trackerIds`` filters on the Redmine stats
// endpoint never reach the route. Setting ``indexes: null`` switches
// the URLSearchParams visitor to repeat format. Applies globally so
// every endpoint with array query params gets it for free.
paramsSerializer: { indexes: null },
});
// Add a request interceptor to add the auth token, context headers, and log backend IP
@ -92,6 +99,20 @@ api.interceptors.request.use(
config.headers['Accept-Language'] = appLanguage;
}
// Send browser IANA timezone (e.g. "Europe/Zurich") so the gateway can
// resolve "now" for AI agents and user-visible time strings without
// hardcoding a server-side default. Mirrors the Accept-Language pattern.
if (config.headers) {
try {
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (browserTimezone) {
config.headers['X-User-Timezone'] = browserTimezone;
}
} catch {
// Older browsers without Intl.DateTimeFormat: backend falls back to UTC
}
}
// Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl();

View file

@ -1,4 +1,7 @@
import { ApiRequestOptions } from '../hooks/useApi';
import type { AttributeType } from '../utils/attributeTypeMapper';
export type { AttributeType };
// ============================================================================
// TYPES & INTERFACES
@ -7,7 +10,7 @@ import { ApiRequestOptions } from '../hooks/useApi';
export interface AttributeDefinition {
name: string;
label: string;
type: 'string' | 'number' | 'date' | 'boolean' | 'enum' | 'text' | 'email' | 'checkbox' | 'select' | 'multiselect' | 'textarea';
type: AttributeType;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;

View file

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

View file

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

View file

@ -4,10 +4,26 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES
// ============================================================================
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
export interface Connection {
id: string;
userId: string;
authority: 'local' | 'google' | 'msft' | 'clickup';
authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
externalId: string;
externalUsername: string;
externalEmail?: string;
@ -15,6 +31,8 @@ export interface Connection {
connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: number; // Backend uses float for UTC timestamp in seconds
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
[key: string]: any; // Allow additional properties
}
@ -37,6 +55,22 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
/** Key of a saved view to apply (server loads groupByLevels, filters, sort from DB). */
viewKey?: string;
/** Explicit grouping levels; when sent (incl. []), overrides the view for this request. */
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface GroupBand {
path: string[];
label: string;
startRowIndex: number;
rowCount: number;
}
export interface GroupLayout {
levels: string[];
bands: GroupBand[];
}
export interface PaginatedResponse<T> {
@ -47,17 +81,21 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
groupLayout?: GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface CreateConnectionData {
id?: string;
userId?: string;
authority?: 'msft' | 'google' | 'clickup';
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority
authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
externalId?: string;
externalUsername?: string;
externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending';
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
connectedAt?: number;
lastChecked?: number;
expiresAt?: number;
@ -103,6 +141,8 @@ export async function fetchConnections(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
@ -136,14 +176,20 @@ export async function createConnection(
/**
* Connect to a service (initiate OAuth)
* Endpoint: POST /api/connections/{connectionId}/connect
*
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
* Required when newly added scopes (e.g. Calendar/Contacts after a
* feature rollout) need to be granted on top of the existing token.
*/
export async function connectService(
request: ApiRequestFunction,
connectionId: string
connectionId: string,
reauth: boolean = false
): Promise<ConnectResponse> {
return await request({
url: `/api/connections/${connectionId}/connect`,
method: 'post'
method: 'post',
data: reauth ? { reauth: true } : undefined,
});
}
@ -221,3 +267,28 @@ 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 }
});
}

View file

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

View file

@ -46,6 +46,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
}
export interface PaginatedResponse<T> {
@ -84,6 +85,7 @@ export async function fetchMandates(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -49,6 +49,8 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: 'asc' | 'desc' }>;
}
export interface PaginatedResponse<T> {
@ -59,6 +61,8 @@ export interface PaginatedResponse<T> {
totalItems: number;
totalPages: number;
};
groupLayout?: import('./connectionApi').GroupLayout;
appliedView?: { viewKey?: string; displayName?: string };
}
export interface CreatePromptData {
@ -110,6 +114,8 @@ export async function fetchPrompts(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (params.groupByLevels !== undefined) paginationObj.groupByLevels = params.groupByLevels;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

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

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

View file

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

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

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

View file

@ -9,6 +9,7 @@ export interface TeamsbotSession {
id: string;
instanceId: string;
mandateId: string;
moduleId?: string;
meetingLink: string;
botName: string;
status: 'pending' | 'joining' | 'active' | 'leaving' | 'ended' | 'error';
@ -169,11 +170,63 @@ export interface MfaChallengeEvent {
// SSE Event Types
export interface TeamsbotSSEEvent {
type: 'transcript' | 'botResponse' | 'analysis' | 'suggestedResponse' | 'statusChange' | 'error' | 'ping' | 'sessionState' | 'ttsDeliveryStatus' | 'mfaChallenge' | 'mfaResolved' | 'chatSendFailed';
type:
| 'transcript'
| 'botResponse'
| 'analysis'
| 'suggestedResponse'
| 'statusChange'
| 'error'
| 'ping'
| 'sessionState'
| 'ttsDeliveryStatus'
| 'mfaChallenge'
| 'mfaResolved'
| 'chatSendFailed'
| 'directorPrompt'
| 'agentRun'
| 'botConnectionState';
data: any;
timestamp?: string;
}
// =========================================================================
// Director Prompts (private operator instructions during a live meeting)
// =========================================================================
export type DirectorPromptMode = 'oneShot' | 'persistent';
export type DirectorPromptStatus =
| 'queued'
| 'running'
| 'succeeded'
| 'failed'
| 'consumed';
export const DIRECTOR_PROMPT_TEXT_LIMIT = 8000;
export const DIRECTOR_PROMPT_FILE_LIMIT = 10;
export interface DirectorPrompt {
id: string;
sessionId: string;
instanceId: string;
operatorUserId: string;
text: string;
mode: DirectorPromptMode;
fileIds: string[];
status: DirectorPromptStatus;
statusMessage?: string;
createdAt: string;
consumedAt?: string;
agentRunId?: string;
responseText?: string;
}
export interface DirectorPromptCreateRequest {
text: string;
mode: DirectorPromptMode;
fileIds?: string[];
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
@ -289,6 +342,29 @@ export async function listSystemBots(instanceId: string): Promise<{ bots: System
return response.data;
}
/**
* Create a new system bot account. The password is encrypted server-side
* before storage; the API never returns the password back. SysAdmin only.
*/
export async function createSystemBot(
instanceId: string,
payload: { email: string; password: string; name?: string },
): Promise<{ bot: SystemBot }> {
const response = await api.post(`/api/teamsbot/${instanceId}/system-bots`, payload);
return response.data;
}
/**
* Delete a system bot account. SysAdmin only.
*/
export async function deleteSystemBot(
instanceId: string,
botId: string,
): Promise<{ deleted: boolean }> {
const response = await api.delete(`/api/teamsbot/${instanceId}/system-bots/${botId}`);
return response.data;
}
/**
* Test TTS voice with AI-generated sample text. Returns base64-encoded audio.
*/
@ -452,3 +528,95 @@ export async function submitMfaCode(
});
return response.data;
}
// =========================================================================
// Director Prompts
// =========================================================================
/**
* Submit a private director prompt to the running bot. Triggers the full
* agent path (web, mail, RAG, etc.) and delivers the answer into the meeting.
*/
export async function submitDirectorPrompt(
instanceId: string,
sessionId: string,
body: DirectorPromptCreateRequest,
): Promise<{ prompt: DirectorPrompt }> {
const response = await api.post(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
body,
);
return response.data;
}
/**
* List director prompts for a session (operator's own prompts only).
*/
export async function listDirectorPrompts(
instanceId: string,
sessionId: string,
): Promise<{ prompts: DirectorPrompt[] }> {
const response = await api.get(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts`,
);
return response.data;
}
/**
* Remove a (typically persistent) director prompt.
*/
export async function deleteDirectorPrompt(
instanceId: string,
sessionId: string,
promptId: string,
): Promise<{ deleted: boolean; promptId: string }> {
const response = await api.delete(
`/api/teamsbot/${instanceId}/sessions/${sessionId}/directorPrompts/${promptId}`,
);
return response.data;
}
// ============================================================================
// Meeting Module API
// ============================================================================
export interface MeetingModule {
id: string;
instanceId: string;
mandateId: string;
ownerUserId: string;
title: string;
seriesType: string;
defaultBotId?: string;
defaultDirectorPrompts?: string;
goals?: string;
kpiTargets?: string;
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;
}): 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}`);
}

View file

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

View file

@ -48,6 +48,7 @@ export interface PaginationParams {
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: Record<string, any>;
search?: string;
viewKey?: string;
}
export interface PaginatedResponse<T> {
@ -152,6 +153,7 @@ export async function fetchUsers(
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (params.viewKey) paginationObj.viewKey = params.viewKey;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);

View file

@ -26,8 +26,16 @@ export interface NodeTypeParameter {
export interface PortField {
name: string;
type: string;
description: Record<string, string>;
/** Plain string or per-language map from the API catalog. */
description: string | Record<string, string>;
required: boolean;
enumValues?: string[] | null;
/** When true, surface at the top of the DataPicker as the most common/recommended pick. */
recommended?: boolean;
/** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */
pickerLabel?: string | null;
/** Backend: segment for one list element (between List field and nested field). */
pickerItemLabel?: string | null;
}
export interface PortSchema {
@ -35,14 +43,39 @@ export interface PortSchema {
fields: PortField[];
}
/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */
export interface DataPickOption {
path: (string | number)[];
pickerLabel: string;
detail?: string;
recommended?: boolean;
iterable?: boolean;
/** For display and optional strict compatibility (e.g. str, Any). */
type?: string;
}
/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */
export type OutputPickHint = DataPickOption;
export interface InputPortDef {
accepts: string[];
}
/** Graph-defined output schema (e.g. form fields from node parameters). */
export interface GraphDefinedSchemaRef {
kind: 'fromGraph';
parameter: string;
}
export interface OutputPortDef {
schema: string;
schema: string | GraphDefinedSchemaRef;
dynamic?: boolean;
deriveFrom?: string;
/**
* When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion).
* Authoritative, like `parameters` for node configuration.
*/
dataPickOptions?: DataPickOption[];
}
export interface NodeType {
@ -66,7 +99,6 @@ export interface NodeType {
action?: string;
};
}
export interface NodeTypeCategory {
id: string;
label: Record<string, string> | string;
@ -77,11 +109,19 @@ export interface SystemVariable {
description: string;
}
/** Single form field type with its canonical port primitive. Delivered by GET /node-types. */
export interface FormFieldType {
id: string;
label: string;
portType: string;
}
export interface NodeTypesResponse {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
}
export interface Automation2GraphNode {
@ -89,7 +129,7 @@ export interface Automation2GraphNode {
type: string;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
}
export interface Automation2Connection {
@ -108,6 +148,10 @@ export interface ExecuteGraphResponse {
success: boolean;
nodeOutputs?: Record<string, unknown>;
error?: string;
/** Soft, non-blocking message displayed alongside a successful response.
* Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern"
* without flipping `success` to `false`. */
warning?: string;
stopped?: boolean;
failedNode?: string;
paused?: boolean;
@ -132,6 +176,8 @@ export interface Automation2Workflow {
label: string;
graph: Automation2Graph;
active?: boolean;
/** Target feature instance for execution data scope (NULL for templates) */
targetFeatureInstanceId?: string | null;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
@ -144,8 +190,8 @@ export interface Automation2Workflow {
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** From PowerOnModel base — record creation timestamp (seconds) */
sysCreatedAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
}
@ -263,8 +309,57 @@ export async function fetchNodeTypes(
});
const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? [];
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
return { nodeTypes, categories };
const portTypeCatalog = data?.portTypeCatalog ?? undefined;
const systemVariables = data?.systemVariables ?? undefined;
const formFieldTypes = data?.formFieldTypes ?? undefined;
console.log(
`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` +
`${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` +
`${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` +
`${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes`
);
return { nodeTypes, categories, portTypeCatalog, systemVariables, formFieldTypes };
}
export interface UpstreamPathEntry {
producerNodeId: string;
producerLabel?: string;
path: (string | number)[];
type: string;
label: string;
scopeOrigin: 'data' | 'loop' | 'system';
}
/**
* POST /api/workflows/{instanceId}/upstream-paths pickable upstream paths for DataPicker / AI.
*/
export async function postUpstreamPaths(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
nodeId: string
): Promise<{ paths: UpstreamPathEntry[] }> {
const data = await request({
url: `/api/workflows/${instanceId}/upstream-paths`,
method: 'post',
data: { graph, nodeId },
});
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
}
/** GET saved workflow graph variant of upstream-paths (requires workflowId). */
export async function getUpstreamPathsSaved(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
nodeId: string
): Promise<{ paths: UpstreamPathEntry[] }> {
const data = await request({
url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`,
method: 'get',
params: { workflowId },
});
return { paths: (data?.paths ?? []) as UpstreamPathEntry[] };
}
/**
@ -353,7 +448,12 @@ export async function fetchWorkflow(
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
body: {
label: string;
graph: Automation2Graph;
invocations?: WorkflowEntryPoint[];
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/workflows/${instanceId}/workflows`,
@ -372,6 +472,7 @@ export async function updateWorkflow(
invocations?: WorkflowEntryPoint[];
active?: boolean;
notifyOnFailure?: boolean;
targetFeatureInstanceId?: string | null;
}
): Promise<Automation2Workflow> {
return await request({
@ -927,3 +1028,95 @@ export async function loadClickupListTasksForDropdown(
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}
// ============================================================================
// AUTOMATION WORKSPACE API (user-facing run workspace)
// ============================================================================
export interface WorkspaceRun {
id: string;
workflowId: string;
workflowLabel?: string;
status: string;
startedAt?: number;
completedAt?: number;
ownerId?: string;
mandateId?: string;
mandateLabel?: string;
targetFeatureInstanceId?: string;
targetInstanceLabel?: string;
costTokens?: number;
costCredits?: number;
error?: string;
}
export interface WorkspaceRunDetail {
run: WorkspaceRun & { nodeOutputs?: Record<string, unknown> };
workflow: {
id: string;
label: string;
targetFeatureInstanceId?: string;
featureInstanceId?: string;
tags?: string[];
} | null;
steps: Array<{
id: string;
runId: string;
nodeId: string;
nodeType: string;
status: string;
inputSnapshot?: Record<string, unknown>;
output?: Record<string, unknown>;
inputFiles?: Array<{ id: string; fileName?: string }>;
outputFiles?: Array<{ id: string; fileName?: string }>;
error?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
tokensUsed?: number;
retryCount?: number;
}>;
files: Array<{
id: string;
fileName?: string;
contentType?: string;
sizeBytes?: number;
}>;
unassignedFiles?: Array<{
id: string;
fileName?: string;
}>;
}
export async function fetchWorkspaceRuns(
request: ApiRequestFunction,
params: {
scope?: 'mine' | 'mandate';
status?: string;
targetInstanceId?: string;
workflowId?: string;
limit?: number;
offset?: number;
} = {},
): Promise<{ runs: WorkspaceRun[]; total: number }> {
const query = new URLSearchParams();
if (params.scope) query.set('scope', params.scope);
if (params.status) query.set('status', params.status);
if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId);
if (params.workflowId) query.set('workflowId', params.workflowId);
if (params.limit) query.set('limit', String(params.limit));
if (params.offset) query.set('offset', String(params.offset));
const qs = query.toString();
const url = `/api/automations/runs${qs ? `?${qs}` : ''}`;
const resp = await request({ url, method: 'get' });
return resp as { runs: WorkspaceRun[]; total: number };
}
export async function fetchWorkspaceRunDetail(
request: ApiRequestFunction,
runId: string,
): Promise<WorkspaceRunDetail> {
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' });
return resp as WorkspaceRunDetail;
}

View file

@ -0,0 +1,467 @@
/* 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: flex;
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
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;
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);
}
:global(.dark-theme) .prefSelect,
:global(.dark-theme) .prefNumber {
background: var(--surface-color);
color: var(--text-primary);
}
:global(.dark-theme) .summary {
border-color: var(--border-color);
}
:global(.dark-theme) .summaryRow {
border-color: var(--border-color);
}

View file

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

View file

@ -6,7 +6,7 @@
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';
import type { ApiRequestFunction, FormFieldType, NodeType, PortField, PortSchema, SystemVariable } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue {
currentNodeId: string;
@ -17,8 +17,15 @@ export interface Automation2DataFlowContextValue {
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[];
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 Automation2DataFlowContext = createContext<Automation2DataFlowContextValue | null>(null);
@ -36,6 +43,9 @@ interface Automation2DataFlowProviderProps {
language: string;
portTypeCatalog?: Record<string, PortSchema>;
systemVariables?: Record<string, SystemVariable>;
formFieldTypes?: FormFieldType[];
instanceId?: string;
request?: ApiRequestFunction;
children: React.ReactNode;
}
@ -48,10 +58,58 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
language,
portTypeCatalog = {},
systemVariables = {},
formFieldTypes = [],
instanceId,
request,
children,
}) => {
const value = useMemo((): Automation2DataFlowContextValue | 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,
@ -61,11 +119,15 @@ export const Automation2DataFlowProvider: React.FC<Automation2DataFlowProviderPr
language,
portTypeCatalog,
systemVariables,
formFieldTypes,
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?.id, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables]);
}, [node, nodes, connections, nodeOutputsPreview, nodeTypes, language, portTypeCatalog, systemVariables, formFieldTypes, instanceId, request]);
return (
<Automation2DataFlowContext.Provider value={value}>

View file

@ -256,6 +256,225 @@
background: var(--bg-primary, #fff);
}
/* Toolbar: context (load + name) is fluid with ellipsis; actions stay right-aligned. */
.canvasHeaderRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
width: 100%;
}
@media (max-width: 900px) {
.canvasHeaderRow {
grid-template-columns: 1fr;
}
}
.canvasHeaderContext {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
flex: 1;
}
/* Closed <select> width must not follow the longest option label. */
.canvasHeaderWorkflowSelect {
flex: 0 0 auto;
width: 12.5rem;
max-width: 100%;
padding: 0.4rem 0.5rem;
min-height: 2rem;
font-size: 0.85rem;
border: 1px solid var(--border-color, #ccc);
border-radius: 6px;
background: var(--bg-primary, #fff);
color: var(--text-primary, #333);
}
.canvasHeaderTitleBlock {
flex: 1 1 8rem;
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%;
}
/* .retryButton sets margin-top for legacy error stacks — not wanted in the toolbar. */
.canvasHeaderActionPanel button {
margin-top: 0;
}
/* Run label switches between "Ausführen", "Ausführen…", "Pflicht-Felder fehlen" — reserve space. */
.canvasHeaderRunButton {
min-width: 12.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
}
@media (max-width: 900px) {
.canvasHeaderActionPanel {
justify-content: flex-start;
}
}
.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 button {
margin-top: 0;
}
.canvasHeaderVersionLabel {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-secondary, #666);
flex: 0 0 auto;
}
.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 {
margin: 0;
font-size: 0.875rem;
@ -507,20 +726,32 @@
cursor: copy;
}
/* Node Config Panel */
/* 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 {
padding: 1rem;
background: var(--bg-primary, #fff);
border-left: 1px solid var(--border-color, #e0e0e0);
width: 280px;
flex-shrink: 0;
box-sizing: border-box;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.nodeConfigPanel h4 {
margin: 0 0 0.75rem 0;
font-size: 0.9rem;
overflow-wrap: anywhere;
}
.nodeConfigNameRow {
@ -547,6 +778,8 @@
font-size: 0.75rem;
color: var(--text-secondary, #666);
line-height: 1.4;
overflow-wrap: anywhere;
word-break: break-word;
}
.nodeConfigPanel label {
@ -572,7 +805,8 @@
min-height: 60px;
}
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips */
/* Kein Primär-Button-Stil für Zeitplan-Karten / Wochentage / Monat-Jahr-Chips
(DataPicker-Dialog wird per createPortal an document.body gehangen nicht hier). */
.nodeConfigPanel
button:not(.scheduleModeCard):not(.scheduleDayOn):not(.scheduleDayOff):not(.scheduleSubModeBtn) {
margin-top: 0.5rem;
@ -1284,53 +1518,112 @@
min-width: 0;
}
/* Data Picker */
/* Data Picker rendered with createPortal(document.body) so it is not affected
by .nodeConfigPanels generic CTA `button` styles. */
.dataPickerOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 11000;
padding: 1rem;
box-sizing: border-box;
}
.dataPickerModal {
background: var(--bg-primary, #fff);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 420px;
max-height: 80vh;
color: var(--text-primary, #1a1a1a);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
border: 1px solid var(--border-color, #e0e0e0);
max-width: min(420px, 100vw - 2rem);
width: 100%;
max-height: min(80vh, 640px);
display: flex;
flex-direction: column;
min-height: 0;
}
.dataPickerHeader {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 1.25rem;
gap: 0.75rem;
padding: 1rem 1.15rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.dataPickerHeaderControls {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.dataPickerTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
line-height: 1.35;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.4rem;
min-width: 0;
}
.dataPickerTypeBadge {
display: inline-block;
font-size: 0.7rem;
font-weight: 400;
font-family: ui-monospace, 'Cascadia Code', monospace;
color: var(--text-secondary, #666);
background: var(--bg-secondary, #f0f0f0);
border: 1px solid var(--border-color, #ddd);
border-radius: 4px;
padding: 0.1rem 0.45rem;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dataPickerStrictLabel {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.7rem;
color: var(--text-secondary, #666);
user-select: none;
}
.dataPickerClose {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary, #666);
padding: 0 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
flex-shrink: 0;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 6px;
font-size: 1.25rem;
line-height: 1;
cursor: pointer;
color: var(--text-primary, #333);
}
.dataPickerClose:hover {
color: var(--text-primary, #333);
background: var(--bg-hover, #e9ecef);
color: var(--text-primary, #1a1a1a);
border-color: var(--border-color, #b8b8b8);
}
.dataPickerBody {
@ -1345,24 +1638,35 @@
}
.dataPickerNodeSection {
margin-bottom: 0.75rem;
margin-bottom: 0.5rem;
}
/* Expandable source row: neutral “list row”, not a primary CTA. */
.dataPickerNodeHeader {
display: flex;
align-items: center;
width: 100%;
padding: 0.5rem 0;
background: none;
border: none;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
background: var(--bg-secondary, #f4f5f7);
border: 1px solid var(--border-color, #dde1e5);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.85rem;
text-align: left;
color: var(--text-primary, #1a1a1a);
margin: 0;
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.dataPickerNodeHeader:hover {
background: var(--bg-hover, #f5f5f5);
border-radius: 4px;
background: var(--bg-hover, #e9ebef);
border-color: var(--border-color, #c8cfd6);
}
.dataPickerNodeHeader:focus-visible {
outline: 2px solid var(--primary-color, #4a6fa5);
outline-offset: 1px;
}
.dataPickerExpandIcon {
@ -1401,6 +1705,105 @@
border-color: var(--primary-color, #007bff);
}
/* Hover safety net: every nested span in a leaf inherits the white text so
* type-hints and meta info stay readable on the blue hover background. */
.dataPickerLeaf:hover * {
color: inherit;
}
/* Inline type-hint after a leaf label, e.g. "documents (List[ActionDocument])". */
.dataPickerLeafType {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Schema-name hint on the node-section header row. */
.dataPickerNodeSchemaHint {
color: var(--text-secondary, #666);
font-size: 10px;
margin-left: 4px;
}
/* Type-mismatch warning badge (⚠) — shown instead of hiding incompatible fields. */
.dataPickerMismatchBadge {
font-size: 10px;
margin-left: 4px;
color: var(--color-warning, #f59e0b);
flex-shrink: 0;
}
/* Recommended pick: subtle highlight on the row */
.dataPickerLeafRecommended {
font-weight: 500;
}
/* "Empfohlen" pill shown on recommended entries */
.dataPickerRecommendedPill {
display: inline-block;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 1px 5px;
border-radius: 10px;
margin-left: 5px;
background: var(--color-primary-light, #dbeafe);
color: var(--color-primary, #2563eb);
flex-shrink: 0;
vertical-align: middle;
}
/* "iterieren" affordance visually distinct (subtle accent), readable on
* the picker's white background and on the leaf's blue hover background. */
.dataPickerIterateBtn {
font-size: 10px;
padding: 2px 6px;
background: var(--bg-secondary, #f5f7fa);
color: var(--primary-color, #007bff);
border: 1px solid var(--border-color, #e0e0e0);
white-space: nowrap;
}
.dataPickerIterateBtn:hover {
background: var(--primary-color, #007bff);
color: #fff;
border-color: var(--primary-color, #007bff);
}
/* Curated picker: disclose technical / rare paths behind a single quiet control. */
.dataPickerCuratedToggle {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.38rem 0.55rem;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-secondary, #5c6370);
background: var(--bg-primary, #fff);
border: 1px dashed var(--border-color, #cfd4dc);
border-radius: 5px;
cursor: pointer;
text-align: center;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.dataPickerCuratedToggle:hover {
color: var(--text-primary, #333);
background: var(--bg-secondary, #f4f6f8);
border-color: var(--border-color, #b8c0cc);
}
.dataPickerCuratedDivider {
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-secondary, #8a9199);
margin: 0.75rem 0 0.35rem 0;
padding-left: 0.15rem;
}
/* Dynamic Value Field */
.dynamicValueField {
display: flex;

View file

@ -22,6 +22,7 @@ import {
archiveVersion,
createTemplateFromWorkflow,
copyTemplate,
importWorkflowFromFile,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
@ -44,6 +45,8 @@ import {
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { findGraphErrors } from '../nodes/shared/paramValidation';
import { getLabel as getParamLabel } from '../nodes/shared/utils';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
@ -55,6 +58,8 @@ import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]';
@ -87,12 +92,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onSourcesChanged,
}) => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [portTypeCatalog, setPortTypeCatalog] = useState<Record<string, unknown>>({});
const [systemVariables, setSystemVariables] = useState<Record<string, unknown>>({});
const [formFieldTypes, setFormFieldTypes] = useState<import('../../../api/workflowApi').FormFieldType[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
@ -122,17 +129,36 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
surface: 'graphEditor',
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
});
const [sidebarWidth, setSidebarWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.sidebarWidth') ?? ''); return v >= 200 && v <= 500 ? v : 280; } catch { return 280; }
});
// Verbose schema toggle: shows the static type-reference block (input/output
// schema) and parameter type-badges in NodeConfigPanel. Only the
// CanvasHeader exposes the toggle (sysadmin-only); persisted to localStorage.
const [verboseSchema, setVerboseSchema] = useState(() => {
try { return localStorage.getItem('flowEditor.verboseSchema') === '1'; } catch { return false; }
});
useEffect(() => {
try { localStorage.setItem('flowEditor.verboseSchema', verboseSchema ? '1' : '0'); } catch { /* ignore */ }
}, [verboseSchema]);
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
useEffect(() => {
@ -178,6 +204,21 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
);
// Phase-4 Schicht-4 — Per-node required-but-unbound errors used by both the
// canvas error badges and the Run-button gate. Graph-level: Save stays
// unconditional (Schicht-4 invariant: WIP must always be persistable).
const nodeErrors = useMemo(
() =>
findGraphErrors(
canvasNodes,
nodeTypes,
(p) => getParamLabel(p.description, language) || p.name,
),
[canvasNodes, nodeTypes, language]
);
const hasGraphErrors = useMemo(() => Object.keys(nodeErrors).length > 0, [nodeErrors]);
const firstErrorNodeId = useMemo(() => Object.keys(nodeErrors)[0] ?? null, [nodeErrors]);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
@ -209,6 +250,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
return;
}
// Phase-4 Schicht-4: Run blockiert bei Pflicht-Fehlern. Save bleibt offen.
if (Object.keys(nodeErrors).length > 0) {
const firstId = Object.keys(nodeErrors)[0];
const firstNode = canvasNodes.find((n) => n.id === firstId);
if (firstNode) setSelectedNode(firstNode);
setExecuteResult({
success: false,
error:
t('Workflow hat Pflicht-Felder ohne Quelle. Bitte erst beheben.') +
(firstNode ? ` (${firstNode.title ?? firstNode.label ?? firstNode.type})` : ''),
});
return;
}
setExecuting(true);
setExecuteResult(null);
try {
@ -226,7 +280,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t, nodeErrors]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
@ -234,11 +288,28 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return;
}
// Phase-4 Schicht-4 / AC 9: Save bleibt bei Pflicht-Fehlern erlaubt,
// aber wir berichten die Anzahl in einem nicht-blockierenden Warning,
// damit der User die WIP-Lücken nicht stillschweigend persistiert.
const errorCount = Object.values(nodeErrors).reduce(
(acc, list) => acc + list.length,
0,
);
const errorNodeCount = Object.keys(nodeErrors).length;
const _buildSaveResult = (): ExecuteGraphResponse => ({
success: true,
warning:
errorCount > 0
? t('Gespeichert mit {n} Pflicht-Fehlern in {m} Nodes.')
.replace('{n}', String(errorCount))
.replace('{m}', String(errorNodeCount))
: undefined,
});
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
setExecuteResult(_buildSaveResult());
} else {
const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'),
@ -253,18 +324,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
targetFeatureInstanceId,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse);
setExecuteResult(_buildSaveResult());
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t]);
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
const handleLoad = useCallback(
async (workflowId: string) => {
@ -275,6 +347,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf];
@ -387,6 +460,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setRegistryCatalog(data.portTypeCatalog as never);
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
if (data.formFieldTypes) setFormFieldTypes(data.formFieldTypes);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
@ -601,14 +675,27 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph]
);
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
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) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`${LOG} rename failed`, e);
showError(t('Workflow umbenennen fehlgeschlagen: {msg}', { msg }));
}
}, [request, instanceId]);
}, [request, instanceId, showError, t]);
const handleAutoLayout = useCallback(() => {
setCanvasNodes((prev) => computeAutoLayout(prev, canvasConnections));
@ -662,9 +749,20 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
selectedNode.type.startsWith(p)
);
[
'input.',
'ai.',
'email.',
'sharepoint.',
'clickup.',
'trigger.',
'flow.',
'file.',
'trustee.',
'context.',
'data.',
'redmine.',
].some((p) => selectedNode.type.startsWith(p));
return (
<div className={styles.container}>
@ -722,6 +820,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
onWorkflowImportedFromFile={async (workflowId) => {
await loadWorkflows();
handleWorkflowSelect(workflowId);
}}
/>
)}
</div>
@ -743,6 +845,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeBlockedReason={
hasGraphErrors
? t('Pflicht-Felder ohne Quelle vorhanden. Klicken markiert die erste betroffene Node.')
: null
}
onExecuteBlockedClick={() => {
if (firstErrorNodeId) {
const n = canvasNodes.find((x) => x.id === firstErrorNodeId);
if (n) setSelectedNode(n);
}
}}
executeResult={executeResult}
versions={versions}
currentVersionId={currentVersionId}
@ -757,6 +870,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -771,6 +889,22 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
nodeErrors={nodeErrors}
onExternalDrop={async (mime, payload) => {
if (mime !== 'application/json+workflow' || !instanceId) 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, instanceId, { fileId });
await loadWorkflows();
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
return true;
} catch (e) {
console.error(`${LOG} workflow drop import failed`, e);
return false;
}
}}
/>
</div>
{configurableSelected && selectedNode && (
@ -783,6 +917,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
language={language}
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
formFieldTypes={formFieldTypes}
instanceId={instanceId}
request={request}
>
<NodeConfigPanel
node={selectedNode}
@ -793,6 +930,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
verboseSchema={verboseSchema}
/>
</Automation2DataFlowProvider>
)}

View file

@ -0,0 +1,99 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.4 (T10): CanvasHeader Run-button gating logic.
// Verifies the AC-9 patch — Save always enabled (unless saving), Run blocked
// when executeBlockedReason is set + warning toast surfaced as amber banner.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/workflowApi';
vi.mock('../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
import { CanvasHeader } from './CanvasHeader';
const _workflows: Automation2Workflow[] = [];
function _renderHeader(overrides: Partial<React.ComponentProps<typeof CanvasHeader>> = {}) {
const props: React.ComponentProps<typeof CanvasHeader> = {
workflows: _workflows,
currentWorkflowId: null,
onWorkflowSelect: () => {},
onNew: () => {},
onSave: () => {},
onExecute: () => {},
saving: false,
executing: false,
hasNodes: true,
executeResult: null,
...overrides,
};
return render(<CanvasHeader {...props} />);
}
describe('CanvasHeader Run-button (T10)', () => {
it('runs `onExecute` when not blocked', async () => {
const onExecute = vi.fn();
_renderHeader({ onExecute });
await userEvent.click(screen.getByRole('button', { name: /Ausführen/i }));
expect(onExecute).toHaveBeenCalledTimes(1);
});
it('shows the "Pflicht-Felder fehlen" label and triggers `onExecuteBlockedClick` instead of `onExecute`', async () => {
const onExecute = vi.fn();
const onExecuteBlockedClick = vi.fn();
_renderHeader({
onExecute,
onExecuteBlockedClick,
executeBlockedReason: '2 Nodes mit Pflicht-Fehlern',
});
const btn = screen.getByRole('button', { name: /Pflicht-Felder fehlen/i });
expect(btn).toHaveAttribute('aria-disabled', 'true');
expect(btn).toHaveAttribute('title', '2 Nodes mit Pflicht-Fehlern');
await userEvent.click(btn);
expect(onExecute).not.toHaveBeenCalled();
expect(onExecuteBlockedClick).toHaveBeenCalledTimes(1);
});
it('disables the Run button while executing or when no nodes are present', () => {
const { rerender } = _renderHeader({ executing: true });
expect(screen.getByRole('button', { name: /Ausführen…/i })).toBeDisabled();
rerender(
<CanvasHeader
workflows={_workflows}
currentWorkflowId={null}
onWorkflowSelect={() => {}}
onNew={() => {}}
onSave={() => {}}
onExecute={() => {}}
saving={false}
executing={false}
hasNodes={false}
executeResult={null}
/>,
);
expect(screen.getByRole('button', { name: /Ausführen/i })).toBeDisabled();
});
});
describe('CanvasHeader executeResult banner (AC-9)', () => {
it('renders the warning text in amber when success+warning is present', () => {
const result: ExecuteGraphResponse = {
success: true,
warning: 'Gespeichert mit 3 Pflicht-Fehlern in 2 Nodes.',
};
_renderHeader({ executeResult: result });
expect(screen.getByText(/Gespeichert mit 3 Pflicht-Fehlern/i)).toBeInTheDocument();
});
it('renders the error text in red when success=false', () => {
const result: ExecuteGraphResponse = { success: false, error: 'Boom' };
_renderHeader({ executeResult: result });
expect(screen.getByText(/Boom/)).toBeInTheDocument();
});
});

View file

@ -8,6 +8,12 @@ import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTempla
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache';
interface TargetInstanceOption {
id: string;
label: string;
}
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
@ -21,6 +27,11 @@ interface CanvasHeaderProps {
saving: boolean;
executing: boolean;
hasNodes: boolean;
/** Phase-4 Schicht-4: when set, the Run button is disabled and the message
* is shown as a tooltip. Click triggers `onExecuteBlockedClick` so the
* parent can navigate the user to the first offending node. */
executeBlockedReason?: string | null;
onExecuteBlockedClick?: () => void;
executeResult: ExecuteGraphResponse | null;
versions?: AutoVersion[];
currentVersionId?: string | null;
@ -35,6 +46,13 @@ interface CanvasHeaderProps {
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
onAutoLayout?: () => void;
/** Sysadmin-only: when true, NodeConfigPanel renders the static
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -56,6 +74,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
saving,
executing,
hasNodes,
executeBlockedReason,
onExecuteBlockedClick,
executeResult,
versions,
currentVersionId,
@ -70,8 +90,14 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onNewFromTemplate,
onWorkflowRename,
onAutoLayout,
verboseSchema,
onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
}) => {
const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
const statusBadge = _getStatusBadge(t);
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
@ -130,35 +156,59 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
[t]
);
const _titleHint =
onWorkflowRename && currentWorkflow
? `${currentWorkflow.label}${t('Klicken zum Umbenennen')}`
: currentWorkflow?.label;
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{/* Workflow name: inline editable */}
<div className={styles.canvasHeaderRow}>
<div className={styles.canvasHeaderContext}>
<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>
<div className={styles.canvasHeaderTitleBlock}>
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
className={styles.canvasHeaderTitle}
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 }}
className={styles.canvasHeaderTitle}
style={{ cursor: onWorkflowRename ? 'pointer' : 'default' }}
onClick={_startNameEdit}
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
title={_titleHint}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
<h4 className={`${styles.canvasHeaderTitle} ${styles.canvasHeaderTitleMuted}`}>
{t('Neuer Workflow')}
</h4>
)}
</div>
{onWorkflowSettings && (
<button
type="button"
@ -170,37 +220,60 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
<FaCog />
</button>
)}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div>
{/* Split "Neu" button */}
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ display: 'flex' }}>
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>
<div ref={newMenuRef} className={styles.canvasHeaderNewSplit}>
<div className={styles.canvasHeaderSplitPair}>
<button
type="button"
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMain}`}
onClick={onNew}
>
{t('Neu')}
</button>
<button
type="button"
className={styles.retryButton}
className={`${styles.retryButton} ${styles.canvasHeaderNewSplitMenu}`}
onClick={() => setNewMenuOpen((p) => !p)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
title={t('Neu aus Vorlage')}
aria-haspopup="menu"
aria-expanded={newMenuOpen}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<div className={styles.canvasHeaderMenuDropdown} role="menu">
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNew(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
role="menuitem"
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
className={styles.canvasHeaderMenuItem}
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
role="menuitem"
>
{t('Aus Vorlage…')}
</button>
@ -213,7 +286,8 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
type="button"
className={styles.retryButton}
onClick={onSave}
disabled={saving || !hasNodes}
disabled={saving}
title={!hasNodes ? t('Workflow ist leer — Speichern legt einen leeren Workflow an.') : undefined}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
@ -231,26 +305,28 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
</button>
)}
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div ref={templateMenuRef} className={styles.canvasHeaderNewSplit}>
<button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
disabled={templateSaving}
title={t('Als Vorlage speichern')}
aria-haspopup="menu"
aria-expanded={templateMenuOpen}
>
{templateSaving ? <FaSpinner className={styles.spinner} /> : <><FaBookmark style={{ marginRight: 4 }} />{t('Als Vorlage')}</>}
</button>
{templateMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<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); }}
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"
>
{scopeLabels[s]}
</button>
@ -259,35 +335,44 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
)}
</div>
)}
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
className={`${styles.retryButton} ${styles.canvasHeaderRunButton}`}
onClick={() => {
if (executeBlockedReason) {
onExecuteBlockedClick?.();
return;
}
onExecute();
}}
disabled={executing || !hasNodes}
aria-disabled={executing || !hasNodes || !!executeBlockedReason}
title={executeBlockedReason ?? undefined}
style={
executeBlockedReason
? {
background: 'rgba(220,53,69,0.10)',
borderColor: 'var(--danger-color, #dc3545)',
color: 'var(--danger-color, #dc3545)',
cursor: 'help',
}
: undefined
}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
<FaSpinner className={styles.spinner} style={{ flexShrink: 0 }} />
{t('Ausführen…')}
</>
) : executeBlockedReason ? (
<>
<FaPlay style={{ opacity: 0.5, flexShrink: 0 }} />
{t('Pflicht-Felder fehlen')}
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
<FaPlay style={{ flexShrink: 0 }} />
{t('Ausführen')}
</>
)}
@ -298,17 +383,32 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
{t('Workspace')}
</button>
)}
{_isSysAdmin && onVerboseSchemaChange && (
<label
className={styles.canvasHeaderSysadmin}
title={t('Sysadmin-Ansicht: zeigt im Node-Panel das statische Typ-Schema (Eingabe/Ausgabe) und Parameter-Typ-Badges.')}
>
<input
type="checkbox"
checked={!!verboseSchema}
onChange={(e) => onVerboseSchemaChange(e.target.checked)}
style={{ margin: 0 }}
/>
{t('Schema-Details')}
</label>
)}
</div>
</div>
{/* Version Selector */}
{currentWorkflowId && versions && versions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>{t('Version:')}</span>
<div className={styles.canvasHeaderVersionRow}>
<span className={styles.canvasHeaderVersionLabel}>{t('Version:')}</span>
<select
className={styles.canvasHeaderVersionSelect}
value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)}
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading}
aria-label={t('Version')}
>
<option value="">{t('Aktuelle')}</option>
{versions.map((v) => (
@ -392,19 +492,27 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
? executeResult.warning
? 'rgba(255,193,7,0.15)'
: '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.warning
? 'var(--warning-color,#ffc107)'
: 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
>
{executeResult.success ? (
executeResult.warning ? (
<> {executeResult.warning}</>
) : (
<>{t('Ausführung abgeschlossen')}</>
)
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den

View file

@ -4,7 +4,7 @@
* AI Chat sidebar for the GraphicalEditor.
* Streams responses via SSE (same pattern as Workspace chat).
* File & data-source attachment UX mirrors WorkspaceInput:
* - Files: drag & drop from FolderTree onto input area, or click in UDB
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/
import React, { useState, useCallback, useEffect, useRef } from 'react';
@ -32,7 +32,7 @@ import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
itemType?: 'file' | 'group';
}
export interface EditorDataSource {
@ -241,7 +241,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
}, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
if (
e.dataTransfer.types.includes('application/tree-items') ||
e.dataTransfer.types.includes('application/group-id') ||
e.dataTransfer.types.includes('application/file-id') ||
e.dataTransfer.types.includes('application/file-ids')
) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
@ -252,6 +257,12 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false);
const groupId = e.dataTransfer.getData('application/group-id');
if (groupId) {
e.preventDefault();
e.stopPropagation();
return;
}
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
e.preventDefault();
@ -282,11 +293,11 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
<span key={pf.fileId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: pf.itemType === 'folder' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'folder' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'folder' ? '#bbdefb' : '#ffe0b2'}`,
background: pf.itemType === 'group' ? '#e3f2fd' : '#fff3e0',
color: pf.itemType === 'group' ? '#1565c0' : '#e65100',
fontWeight: 500, border: `1px solid ${pf.itemType === 'group' ? '#bbdefb' : '#ffe0b2'}`,
}}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{pf.itemType === 'group' ? '\uD83D\uDCC2' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,

View file

@ -48,7 +48,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
const list = q
? workflows.filter((w) => (w.label || '').toLowerCase().includes(q))
: [...workflows];
list.sort((a, b) => (b.lastStartedAt || b.createdAt || 0) - (a.lastStartedAt || a.createdAt || 0));
list.sort((a, b) => (b.lastStartedAt || b.sysCreatedAt || 0) - (a.lastStartedAt || a.sysCreatedAt || 0));
return list;
}, [workflows, search]);
@ -85,7 +85,7 @@ export const EditorWorkflowChatList: React.FC<EditorWorkflowChatListProps> = ({
) : (
filtered.map((wf) => {
const isActive = wf.id === currentWorkflowId;
const ts = wf.lastStartedAt || wf.createdAt;
const ts = wf.lastStartedAt || wf.sysCreatedAt;
return (
<div
key={wf.id}

View file

@ -4,7 +4,7 @@
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NodeType } from '../../../api/workflowApi';
import type { GraphDefinedSchemaRef, NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -23,7 +23,7 @@ export interface CanvasNode {
outputs: number;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>;
}
export interface CanvasConnection {
@ -108,6 +108,12 @@ export function computeAutoLayout(
});
}
function _outputSchemaName(schema: string | GraphDefinedSchemaRef | undefined): string {
if (typeof schema === 'string') return schema;
if (schema && typeof schema === 'object' && schema.kind === 'fromGraph') return 'FormPayload';
return '';
}
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
function _checkConnectionCompatibility(
sourceNode: CanvasNode,
@ -124,11 +130,12 @@ function _checkConnectionCompatibility(
const tgtPort = tgtType.inputPorts[targetInputIdx];
if (!srcPort || !tgtPort) return 'ok';
const srcSchema = srcPort.schema;
const srcSchema = _outputSchemaName(srcPort.schema as string | GraphDefinedSchemaRef);
const accepts = tgtPort.accepts;
if (!accepts || accepts.length === 0) return 'ok';
if (accepts.includes('Transit')) return 'ok';
if (accepts.includes(srcSchema)) return 'ok';
if (srcSchema && accepts.includes(srcSchema)) return 'ok';
if (srcSchema?.startsWith('FormPayload') && accepts.includes('FormPayload')) return 'ok';
return 'warning';
}
@ -143,6 +150,14 @@ interface FlowCanvasProps {
getCategoryIcon: (category: string) => React.ReactNode;
onSelectionChange?: (node: CanvasNode | null) => void;
highlightedNodeIds?: Record<string, string>;
/** Phase-4: per-node "required-but-unbound" param errors. The canvas renders
* a red error badge in the top-right of each node whose id is a key. */
nodeErrors?: Record<string, Array<{ paramName: string; paramLabel: string }>>;
/** Wenn ein Drop mit einer registrierten externen MIME-Type ankommt
* (z. B. ``application/json+workflow`` aus der UDB-FilesTab),
* wird dieser Callback statt der Node-Type-Drop-Logik aufgerufen.
* Liefert `true` zurück, wenn der Drop als "verarbeitet" gilt. */
onExternalDrop?: (mime: string, payload: unknown) => Promise<boolean> | boolean;
}
const HIGHLIGHT_COLORS: Record<string, string> = {
@ -162,6 +177,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
getCategoryIcon,
onSelectionChange,
highlightedNodeIds,
nodeErrors,
onExternalDrop,
}) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null);
@ -256,8 +273,32 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
}, [connections]);
const handleDrop = useCallback(
(e: React.DragEvent) => {
async (e: React.DragEvent) => {
e.preventDefault();
// 1) externe Drop-Targets (z. B. ``application/json+workflow`` aus UDB-FilesTab)
if (onExternalDrop) {
const reservedMimes = new Set([
'application/json',
'application/tree-items',
'application/group-file-ids',
'application/file-id',
'application/file-ids',
'application/group-id',
]);
for (const mime of Array.from(e.dataTransfer.types)) {
if (!mime.startsWith('application/') || reservedMimes.has(mime)) continue;
const raw = e.dataTransfer.getData(mime);
if (!raw) continue;
try {
const payload = JSON.parse(raw);
const handled = await onExternalDrop(mime, payload);
if (handled) return;
} catch {
// andere Drag-Source → ignorieren, Standard-Pfad versuchen
}
}
}
// 2) Standard: Node-Type aus der NodeSidebar
const raw = e.dataTransfer.getData('application/json');
if (!raw || !containerRef.current) return;
try {
@ -269,7 +310,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
} catch (_) {}
},
[onDropNodeType, panOffset, zoom]
[onDropNodeType, onExternalDrop, panOffset, zoom]
);
const handleHandleMouseDown = useCallback(
@ -761,6 +802,9 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
const wireSourceNode =
connectingFrom && !selectedConnectionId ? nodes.find((n) => n.id === connectingFrom.nodeId) : null;
const isSelected = selectedNodeIds.has(node.id);
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
const displayTitle = node.title ?? node.label ?? getLabel(node);
@ -805,15 +849,54 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
title={t('Dieser Schritt nutzt AI und verbraucht Credits')}
/>
)}
{nodeErrors?.[node.id]?.length ? (
<div
role="status"
title={
t('Pflicht-Felder ohne Quelle: ') +
nodeErrors[node.id].map((e) => e.paramLabel).join(', ')
}
style={{
position: 'absolute',
top: -8,
right: -8,
minWidth: 20,
height: 20,
borderRadius: 10,
padding: '0 6px',
background: 'var(--danger-color, #dc3545)',
color: '#fff',
fontSize: 11,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
zIndex: 5,
pointerEvents: 'auto',
}}
>
{nodeErrors[node.id].length}
</div>
) : null}
{handles.map(({ index, isOutput }) => {
const pos = getHandlePosition(node, index);
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
const selConn = selectedConnectionId ? connections.find((c) => c.id === selectedConnectionId) : null;
const isCurrentTargetOfSelection =
selConn && selConn.targetId === node.id && selConn.targetHandle === index;
let wireTargetOk = true;
if (!isOutput && connectingFrom && !selectedConnectionId && wireSourceNode) {
const sourceOutputIdx =
connectingFrom.handleIndex >= wireSourceNode.inputs
? connectingFrom.handleIndex - wireSourceNode.inputs
: 0;
wireTargetOk =
_checkConnectionCompatibility(wireSourceNode, sourceOutputIdx, node, index, nodeTypes) === 'ok';
}
const canConnect =
isOutput ||
(!used && connectingFrom) ||
(!used && !!connectingFrom && (!selectedConnectionId ? wireTargetOk : true)) ||
(!!selectedConnectionId && !isOutput && (!used || isCurrentTargetOfSelection));
const nt = nodeTypeMap[node.type];
const outputLabel = isOutput && nt?.outputLabels ? nt.outputLabels[index - node.inputs] : undefined;

View file

@ -3,12 +3,15 @@
* Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, PortSchema } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils';
import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import { RequiredAttributePicker } from '../nodes/shared/RequiredAttributePicker';
import { findRequiredErrors } from '../nodes/shared/paramValidation';
import { useAutomation2DataFlow } from '../context/Automation2DataFlowContext';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -22,6 +25,9 @@ interface NodeConfigPanelProps {
onNodeUpdate?: (nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => void;
instanceId?: string;
request?: ApiRequestFunction;
/** When true, render developer-oriented sections (Schema-Typ-Referenz,
* parameter type-badges). Toggle in CanvasHeader, sysadmin-only. */
verboseSchema?: boolean;
}
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
@ -32,6 +38,7 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
onNodeUpdate,
instanceId,
request,
verboseSchema = false,
}) => {
const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({});
@ -72,11 +79,53 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
[onParametersChange]
);
const dataFlow = useAutomation2DataFlow();
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]);
if (!node || !nodeType) return null;
const isTrigger = node.type.startsWith('trigger.');
const showNameField = onNodeUpdate && !isTrigger;
const parameters = nodeType.parameters || [];
const parameters = sortedParameters;
const inputPortDefs = nodeType.inputPorts ?? {};
const outputPortDefs = nodeType.outputPorts ?? {};
const inputPortEntries = Object.entries(inputPortDefs);
const outputPortEntries = Object.entries(outputPortDefs);
const hasPortInfo = inputPortEntries.length > 0 || outputPortEntries.length > 0;
return (
<div className={styles.nodeConfigPanel}>
@ -101,12 +150,136 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
{getLabel(nodeType.description, language)}
</p>
)}
{hasPortInfo && verboseSchema && (
<details className={styles.nodeConfigPorts ?? ''} style={{ margin: '0 0 0.75rem', fontSize: '0.7rem' }}>
<summary
style={{
cursor: 'pointer',
color: 'var(--text-secondary)',
fontWeight: 500,
padding: '0.15rem 0',
fontStyle: 'italic',
}}
title={t('Statische Schema-Referenz f\u00fcr diesen Node-Typ \u2014 keine Live-Daten')}
>
{t('Schema (Typ-Referenz, Sysadmin-Ansicht)')}
</summary>
{inputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B07'} {t('Eingabe')}
</div>
{inputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`in-${idx}`}
portIndex={Number(idx)}
schemaNames={def?.accepts ?? []}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
{outputPortEntries.length > 0 && (
<div style={{ marginTop: '0.4rem' }}>
<div style={{ color: 'var(--text-secondary)', fontWeight: 600, marginBottom: 2 }}>
{'\u2B06'} {t('Ausgabe')}
</div>
{outputPortEntries.map(([idx, def]) => (
<_PortFieldList
key={`out-${idx}`}
portIndex={Number(idx)}
schemaNames={_schemaNamesFromOutputPort(def)}
catalog={portTypeCatalog}
emptyLabel={t('keine Felder')}
language={language}
/>
))}
</div>
)}
</details>
)}
{requiredErrors.length > 0 && (
<div
style={{
marginBottom: 8,
padding: '6px 10px',
background: 'rgba(220,53,69,0.10)',
borderLeft: '3px solid var(--danger-color, #dc3545)',
borderRadius: 4,
fontSize: 12,
color: 'var(--danger-color, #dc3545)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
title={requiredErrorTooltip || undefined}
>
{t('Pflicht-Felder ohne Quelle:')}{' '}
<strong>{requiredErrors.map((e) => e.paramLabel).join(', ')}</strong>
</div>
)}
{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;
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={params[param.name] ?? param.default}
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={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
key={param.name}
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
@ -115,6 +288,108 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
request={request}
nodeType={node.type}
/>
</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 (
<ul key={name} style={{ margin: '2px 0 4px 16px', padding: 0, listStyle: 'none' }}>
{fields.map((f) => (
<li key={f.name} style={{ fontSize: '0.7rem', lineHeight: 1.4, color: 'var(--text-secondary)' }}>
<span style={{ fontFamily: 'monospace', color: 'var(--text-primary)' }}>{f.name}</span>
<span style={{ color: 'var(--text-tertiary)' }}>{`: ${f.type}`}</span>
{!f.required && <span style={{ color: 'var(--text-tertiary)' }}>{' (optional)'}</span>}
{f.description && (
<div style={{ color: 'var(--text-secondary)', marginLeft: 4 }}>
{getLabel(f.description, language)}
</div>
)}
</li>
))}
</ul>
);
})}
</div>

View file

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

View file

@ -0,0 +1,372 @@
/**
* 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 { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
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 = useAutomation2DataFlow();
const rows = normalizeRows(value, allParams);
const [pickerRow, setPickerRow] = React.useState<number | null>(null);
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const setRows = (next: ContextAssignmentRow[]) => {
onChange(next.length ? next : [defaultRow()]);
};
const setRow = (idx: number, patch: Partial<ContextAssignmentRow>) => {
const next = [...rows];
next[idx] = { ...next[idx], ...patch };
setRows(next);
};
const addRow = () => setRows([...rows, defaultRow()]);
const removeRow = (idx: number) => {
if (rows.length <= 1) {
onChange([defaultRow()]);
return;
}
setRows(rows.filter((_, i) => i !== idx));
};
const labelForRef = (ref: DataRef | SystemVarRef): string => {
if (isSystemVar(ref)) {
return t('System') + `: ${ref.variable}`;
}
const nodeLabel =
dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === ref.nodeId) ?? { id: ref.nodeId },
) ?? ref.nodeId;
const pathStr = ref.path.length > 0 ? ref.path.map(String).join('.') : null;
return pathStr ? `${nodeLabel}${pathStr}` : nodeLabel;
};
const onPickRef = (idx: number, picked: DataRef | SystemVarRef) => {
if (!isRef(picked) && !isSystemVar(picked)) return;
setRow(idx, { upstreamRef: picked });
setPickerRow(null);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 6, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
</label>
{rows.map((row, idx) => (
<div key={idx} style={ROW_BOX}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 }}>
<input
type="text"
placeholder={t('Ziel-Schlüssel im Kontext')}
value={row.contextKey}
onChange={(e) => setRow(idx, { contextKey: e.target.value })}
style={{ flex: '2 1 140px', minWidth: 120, padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<select
value={row.valueSource}
onChange={(e) => {
const vs = e.target.value as ValueSource;
const patch: Partial<ContextAssignmentRow> = { valueSource: vs };
if (vs === 'literal') patch.upstreamRef = undefined;
if (vs === 'pickUpstream') patch.literal = '';
setRow(idx, patch);
}}
style={{ flex: '1 1 160px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="pickUpstream">{t('Wert aus Daten-Picker')}</option>
<option value="literal">{t('Fester Wert')}</option>
<option value="humanTask">{t('Benutzer setzt Wert (Task)')}</option>
</select>
<select
value={row.mode || 'set'}
onChange={(e) => setRow(idx, { mode: e.target.value as ContextAssignmentRow['mode'] })}
style={{ flex: '1 1 120px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{MODES.map((m) => (
<option key={m.id} value={m.id}>
{m.labelDe}
</option>
))}
</select>
<select
value={row.valueType || 'str'}
onChange={(e) => setRow(idx, { valueType: e.target.value })}
style={{ flex: '0 1 90px', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
>
{TYPES.map((tp) => (
<option key={tp} value={tp}>
{tp}
</option>
))}
</select>
<button type="button" style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }} onClick={() => removeRow(idx)}>
×
</button>
</div>
{row.valueSource === 'pickUpstream' && (
<div>
{row.upstreamRef && (isRef(row.upstreamRef) || isSystemVar(row.upstreamRef)) && (
<div style={CHIP_STYLE}>
<span style={{ flex: 1, color: '#2d6a2d' }}>{labelForRef(row.upstreamRef)}</span>
<button type="button" style={REMOVE_BTN} onClick={() => setRow(idx, { upstreamRef: undefined })} title={t('Entfernen')}>
×
</button>
</div>
)}
<button
type="button"
onClick={() => setPickerRow(idx)}
disabled={!hasSources}
style={{
marginTop: 4,
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #1c5fb5',
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{hasSources ? t('Datenquelle wählen …') : t('Keine vorherigen Nodes verfügbar')}
</button>
<input
type="text"
placeholder={t('Optional: Zusatz-Pfad (z. B. payload.status)')}
value={row.sourcePath || ''}
onChange={(e) => setRow(idx, { sourcePath: e.target.value })}
style={{ width: '100%', marginTop: 6, padding: '4px 6px', borderRadius: 4, border: '1px dashed #aaa', fontSize: 11 }}
/>
</div>
)}
{row.valueSource === 'literal' && (
<input
type="text"
placeholder={t('Wert (oder JSON für object/list)')}
value={row.literal ?? ''}
onChange={(e) => setRow(idx, { literal: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
)}
{row.valueSource === 'humanTask' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<input
type="text"
placeholder={t('Titel der Aufgabe (optional)')}
value={row.taskTitle || ''}
onChange={(e) => setRow(idx, { taskTitle: e.target.value })}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc' }}
/>
<textarea
placeholder={t('Beschreibung für den Bearbeiter (optional)')}
value={row.taskDescription || ''}
onChange={(e) => setRow(idx, { taskDescription: e.target.value })}
rows={2}
style={{ width: '100%', padding: '4px 6px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
/>
</div>
)}
</div>
))}
<button type="button" onClick={addRow} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>
{t('Zuweisung hinzufügen')}
</button>
{dataFlow && pickerRow != null && (
<DataPicker
open
onClose={() => setPickerRow(null)}
onPick={(picked) => onPickRef(pickerRow, picked)}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType="Any"
/>
)}
</div>
);
};

View file

@ -0,0 +1,181 @@
/**
* 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 { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
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 = useAutomation2DataFlow();
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>
);
};

View file

@ -0,0 +1,168 @@
/**
* DataRefRenderer Pick-not-Push attribute binding using the existing
* hierarchical DataPicker.
*
* For required typed parameters (e.g. ``documentList: DocumentList``) where
* the user must explicitly bind to an upstream node's typed output. Replaces
* the legacy ``frontendType: "hidden"`` so the binding becomes visible and
* editable directly in the node config panel.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { isRef, type DataRef, type SystemVarRef } from '../shared/dataRef';
import type { FieldRendererProps } from './index';
export const DataRefRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = React.useState(false);
const currentRef = isRef(value) ? (value as DataRef) : null;
const isMissing = param.required && !currentRef;
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const currentNodeLabel = currentRef
? dataFlow?.getNodeLabel(
dataFlow.nodes.find((n) => n.id === currentRef.nodeId) ?? { id: currentRef.nodeId },
) ?? currentRef.nodeId
: null;
const onPick = (picked: DataRef | SystemVarRef) => {
onChange(picked);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2, fontWeight: 600 }}>
{param.description || param.name}
{param.required && <span style={{ color: '#d9534f', marginLeft: 4 }}>*</span>}
{param.type && (
<span
style={{
marginLeft: 6,
fontFamily: 'monospace',
fontWeight: 500,
fontSize: 10,
color: '#666',
background: '#eef',
padding: '0 4px',
borderRadius: 3,
}}
title={t('Erwarteter Typ')}
>
{param.type}
</span>
)}
</label>
{currentRef && (
<div
style={{
padding: '4px 8px',
background: '#eaf6e8',
border: '1px solid #5cb85c',
borderRadius: 4,
fontSize: 12,
marginBottom: 4,
display: 'flex',
alignItems: 'center',
gap: 6,
}}
title={t('Aktive DataRef-Bindung')}
>
<span style={{ color: '#3c763d', fontWeight: 700 }}>{'\u2190'}</span>
<span style={{ fontFamily: 'monospace', color: '#3c763d', flex: 1 }}>
{currentNodeLabel}
{currentRef.path.length > 0 && (
<>
<span style={{ color: '#999' }}>{' \u2192 '}</span>
{currentRef.path.map((p) => String(p)).join('.')}
</>
)}
</span>
{currentRef.expectedType && (
<span style={{ fontSize: 10, color: '#666', fontFamily: 'monospace' }}>
{currentRef.expectedType}
</span>
)}
<button
type="button"
onClick={() => onChange(undefined)}
title={t('Bindung entfernen')}
style={{
padding: '0 6px',
border: '1px solid #5cb85c',
borderRadius: 3,
background: '#fff',
color: '#3c763d',
cursor: 'pointer',
fontSize: 11,
}}
>
×
</button>
</div>
)}
{isMissing && (
<div
style={{
padding: '4px 8px',
background: '#fdecea',
border: '1px solid #d9534f',
borderRadius: 4,
fontSize: 12,
color: '#a94442',
marginBottom: 4,
}}
>
{t('Pflicht-Bindung fehlt — Quelle aus Upstream-Node wählen.')}
</div>
)}
<button
type="button"
onClick={() => setPickerOpen(true)}
disabled={!hasSources}
style={{
width: '100%',
padding: '4px 8px',
borderRadius: 4,
border: `1px solid ${isMissing ? '#d9534f' : currentRef ? '#5cb85c' : '#1c5fb5'}`,
background: hasSources ? '#fff' : '#f5f5f5',
color: hasSources ? '#1c5fb5' : '#999',
cursor: hasSources ? 'pointer' : 'not-allowed',
fontSize: 12,
textAlign: 'left',
}}
>
{!hasSources
? t('Keine vorherigen Nodes verfügbar')
: currentRef
? t('Bindung ändern …')
: t('Quelle wählen …')}
</button>
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={onPick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -0,0 +1,158 @@
/**
* FeatureInstancePicker renderer for frontendType="featureInstance".
*
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
*
* Behavior matches the rest of the editor:
* - 0 results -> hint to create a feature instance for this mandate
* - 1 result -> auto-pick (no manual click required)
* - N results -> <select>
*
* The bound value is a plain `<id>` string so backend adapters can keep
* using `featureInstanceId` lookups unchanged. Type stays
* `FeatureInstanceRef[<code>]` on the parameter so DataPicker / RequiredAttributePicker
* filter correctly.
*/
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import type { FieldRendererProps } from './index';
type FeatureInstanceOption = { id: string; label: string };
export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
param,
value,
onChange,
instanceId,
request,
}) => {
const { t } = useLanguage();
const featureCode =
(param.frontendOptions?.featureCode as string | undefined) || undefined;
const [instances, setInstances] = React.useState<FeatureInstanceOption[]>([]);
const [loading, setLoading] = React.useState(false);
const [loadError, setLoadError] = React.useState<string | null>(null);
const autoSingleRef = React.useRef(false);
React.useEffect(() => {
if (!instanceId || !request || !featureCode) return;
setLoading(true);
setLoadError(null);
request({
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
method: 'get',
})
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setInstances((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
})
.catch((err: unknown) => {
console.error('FeatureInstancePicker: failed to load instances', err);
setInstances([]);
setLoadError(err instanceof Error ? err.message : String(err));
})
.finally(() => setLoading(false));
}, [instanceId, request, featureCode]);
React.useEffect(() => {
if (instances.length !== 1 || autoSingleRef.current) return;
if (value !== '' && value !== undefined && value !== null) return;
autoSingleRef.current = true;
onChange(instances[0].id);
}, [instances, value, onChange]);
const strVal = typeof value === 'string' ? value : '';
const codeLabel = featureCode ?? t('Feature');
return (
<div style={{ marginBottom: 8, minWidth: 0, maxWidth: '100%' }}>
<label
style={{
display: 'block',
fontSize: 12,
marginBottom: 2,
color: 'var(--text-primary)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{param.description || param.name}
</label>
{loading && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Lade…')}</div>
)}
{!loading && instances.length === 0 && !loadError && (
<div
style={{
fontSize: 11,
color: 'var(--text-secondary)',
marginBottom: 4,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{t('Keine {code}-Instanz im aktiven Mandanten — bitte in der Admin-Konsole anlegen.', { code: codeLabel })}
</div>
)}
{!loading && instances.length === 1 && (
<div
style={{
fontSize: 12,
marginBottom: 4,
color: 'var(--text-primary)',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: 4,
padding: '4px 8px',
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
title={`${t('Einziger {code}-Mandant — automatisch gewählt.', { code: codeLabel })} — ${instances[0].label}`}
>
{instances[0].label}
</div>
)}
{!loading && instances.length > 1 && (
<select
value={strVal}
onChange={(e) => onChange(e.target.value)}
style={{
width: '100%',
maxWidth: '100%',
boxSizing: 'border-box',
padding: '4px 8px',
borderRadius: 4,
border: '1px solid var(--border-color)',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
<option value="">{t('{code}-Mandant wählen', { code: codeLabel })}</option>
{instances.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
)}
{loadError && (
<div
style={{
fontSize: 11,
color: 'var(--danger-color, #c00)',
marginTop: 2,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{t('Mandanten-Liste konnte nicht geladen werden')}
</div>
)}
</div>
);
};
export default FeatureInstancePicker;

View file

@ -0,0 +1,171 @@
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
*/
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
function _refToTemplateToken(ref: DataRef): string {
const pathSegs = (ref.path ?? []).map((p) => String(p));
if (pathSegs.length === 0) {
return `{{${ref.nodeId}}}`;
}
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
}
function _insertAtCursor(
text: string,
insert: string,
start: number,
end: number,
): { next: string; caret: number } {
const next = text.slice(0, start) + insert + text.slice(end);
const caret = start + insert.length;
return { next, caret };
}
function _parseTokensInTemplate(
template: string,
nodes: Array<{ id: string; title?: string }>,
getNodeLabel: (n: { id: string; title?: string }) => string,
): Array<{ raw: string; label: string }> {
const out: Array<{ raw: string; label: string }> = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
while ((m = re.exec(template)) !== null) {
const inner = m[1].trim();
if (seen.has(inner)) continue;
seen.add(inner);
const parts = inner.split('.');
const nodeId = parts[0];
if (!nodeId) continue;
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
const ref: DataRef = { type: 'ref', nodeId, path };
const label = formatRefLabel(ref, nodes, (id) =>
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
);
out.push({ raw: m[0], label });
}
return out;
}
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const strVal = typeof value === 'string' ? value : value != null ? String(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 tokenLegend = useMemo(() => {
if (!dataFlow || !strVal.includes('{{')) return [];
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
}, [strVal, dataFlow]);
const handlePick = useCallback(
(picked: DataRef | SystemVarRef) => {
if (isSystemVar(picked)) {
setPickerOpen(false);
return;
}
if (!isRef(picked)) {
setPickerOpen(false);
return;
}
const token = _refToTemplateToken(picked);
const el = textareaRef.current;
const start = el?.selectionStart ?? strVal.length;
const end = el?.selectionEnd ?? strVal.length;
const { next, caret } = _insertAtCursor(strVal, token, start, end);
onChange(next);
setPickerOpen(false);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
},
[onChange, strVal],
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className={styles.startsInput}
disabled={!hasSources}
onClick={() => setPickerOpen(true)}
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
>
{t('Variable einfügen…')}
</button>
{!hasSources && (
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
)}
</div>
<textarea
ref={textareaRef}
value={strVal}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
rows={6}
spellCheck={false}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: 4,
border: '1px solid #ccc',
resize: 'vertical',
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
minHeight: 120,
}}
/>
{tokenLegend.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{tokenLegend.map((row) => (
<li key={row.raw} style={{ marginBottom: 2 }}>
<code style={{ fontSize: 10 }}>{row.raw}</code>
{' — '}
{row.label}
</li>
))}
</ul>
</div>
)}
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={handlePick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -0,0 +1,267 @@
/**
* userFileFolder FormGeneratorTree embedded: combobox-style trigger + expandable tree.
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import { FaFolderPlus } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { usePrompt } from '../../../../hooks/usePrompt';
import { getFolderTree, createFolder } from '../../../../api/fileApi';
import { FormGeneratorTree } from '../../../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../../../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../../../FormGenerator/FormGeneratorTree';
import type { FieldRendererProps } from './index';
export const UserFileFolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, request }) => {
const { t } = useLanguage();
const { prompt, PromptDialog } = usePrompt();
const [panelOpen, setPanelOpen] = useState(false);
/** Remount embedded tree after create/rename elsewhere */
const [treeRefreshKey, setTreeRefreshKey] = useState(0);
const [creating, setCreating] = useState(false);
/** Display name for saved folderId (resolved from API when graph loads). */
const [pickedName, setPickedName] = useState<string | null>(null);
const provider = useMemo(() => createFolderFileProvider({ includeFiles: false }), []);
const strVal = typeof value === 'string' ? value : '';
const rootSelected = strVal === '';
useEffect(() => {
if (!strVal) {
setPickedName(null);
return;
}
if (!request) return;
let cancelled = false;
getFolderTree(request, 'me')
.then((folders) => {
if (cancelled) return;
const f = folders.find((x) => x.id === strVal);
setPickedName(f?.name ?? null);
})
.catch(() => {
if (!cancelled) setPickedName(null);
});
return () => {
cancelled = true;
};
}, [strVal, request]);
const handleNodeClick = useCallback(
(node: TreeNode) => {
if (node.type === 'folder') {
setPickedName(node.name);
onChange(node.id);
setPanelOpen(false);
}
},
[onChange],
);
const clearFolder = useCallback(() => {
onChange('');
setPickedName(null);
}, [onChange]);
const triggerLabel = strVal ? (pickedName ?? '…') : t('Wähle einen Zielordner');
const handleCreateFolder = useCallback(async () => {
if (!request || creating) return;
const parentHint = strVal && pickedName ? ` („${pickedName}“)` : strVal ? '' : ' (Stamm)';
const entered = await prompt(`Ordnername${parentHint}:`, {
title: 'Neuer Ordner',
placeholder: 'Ordnername',
confirmLabel: t('Anlegen'),
});
const trimmed = entered?.trim();
if (!trimmed) return;
setCreating(true);
try {
const parentId = strVal || null;
const folder = await createFolder(request, trimmed, parentId);
setPickedName(folder.name);
onChange(folder.id);
setTreeRefreshKey((k) => k + 1);
} catch {
// stay silent in minimal UI; devtools / global handler may log
} finally {
setCreating(false);
}
}, [request, creating, strVal, pickedName, prompt, onChange, t]);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{!request && (
<div style={{ fontSize: 11, color: '#888' }}>{t('Ordnerliste nicht verfügbar (keine API-Anbindung).')}</div>
)}
{request && (
<>
<div
style={{
display: 'flex',
width: '100%',
alignItems: 'stretch',
borderRadius: 6,
border: '1px solid var(--color-border, #cbd5e1)',
background: 'var(--table-header-bg, #f1f5f9)',
overflow: 'hidden',
marginBottom: panelOpen ? 6 : 0,
}}
>
<button
type="button"
onClick={() => setPanelOpen((o) => !o)}
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
minWidth: 0,
padding: '8px 10px',
border: 'none',
background: 'transparent',
cursor: 'pointer',
fontSize: 12,
textAlign: 'left',
color: 'var(--color-text, #334155)',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
{triggerLabel}
</span>
<span aria-hidden style={{ flexShrink: 0, fontSize: 10, opacity: 0.65 }}>
{panelOpen ? '▾' : '▸'}
</span>
</button>
{strVal ? (
<button
type="button"
title={t('Zielordner entfernen (Stamm — Meine Dateien)')}
aria-label={t('Zielordner entfernen')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
clearFolder();
}}
style={{
flexShrink: 0,
width: 36,
border: 'none',
borderLeft: '1px solid var(--color-border, #cbd5e1)',
background: 'transparent',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
color: 'var(--color-text-secondary, #64748b)',
padding: 0,
}}
>
×
</button>
) : null}
</div>
{panelOpen && (
<div
style={{
border: '1px solid var(--color-border, #e2e8f0)',
borderRadius: 8,
overflow: 'hidden',
background: 'var(--color-bg, #fff)',
}}
>
<div
style={{
display: 'flex',
alignItems: 'stretch',
borderBottom: '1px solid var(--color-border, #e2e8f0)',
background: 'var(--table-header-bg, #f8fafc)',
}}
>
<div
role="button"
tabIndex={0}
onClick={() => {
clearFolder();
setPanelOpen(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearFolder();
setPanelOpen(false);
}
}}
style={{
flex: 1,
padding: '8px 12px',
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
minHeight: 36,
background: rootSelected ? 'rgba(37, 99, 235, 0.12)' : 'transparent',
}}
>
{t('Stamm — Meine Dateien')}
</div>
<button
type="button"
aria-label={t('Neuen Ordner erstellen')}
title={
creating
? t('Wird angelegt…')
: strVal
? `Unterordner von: ${pickedName ?? '…'}`
: 'Unter dem Stamm (oberste Ebene)'
}
disabled={creating}
onClick={(e) => {
e.stopPropagation();
void handleCreateFolder();
}}
style={{
flexShrink: 0,
width: 40,
minHeight: 36,
alignSelf: 'stretch',
border: 'none',
borderLeft: '1px solid var(--color-border, #e2e8f0)',
background: 'transparent',
cursor: creating ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--primary-color, #2563eb)',
opacity: creating ? 0.5 : 1,
}}
>
<FaFolderPlus size={14} aria-hidden />
</button>
</div>
<FormGeneratorTree
key={`user-folder-tree-${treeRefreshKey}`}
provider={provider}
ownership="own"
compact
allowCreateFolder={false}
showFilter={false}
emptyMessage={t('Noch keine Ordner')}
onNodeClick={handleNodeClick}
embedMaxHeight={240}
hideRowActionButtons
hideSectionHeader
enableDragDrop
/>
</div>
)}
<PromptDialog />
</>
)}
</div>
);
};

View file

@ -6,6 +6,8 @@
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
export interface FieldRendererProps {
param: NodeTypeParameter;
@ -26,6 +28,16 @@ export type FieldRendererComponent = ComponentType<FieldRendererProps>;
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { toApiGraph } from '../shared/graphUtils';
import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer';
import { ContextBuilderRenderer } from './ContextBuilderRenderer';
import { ContextAssignmentsEditor } from './ContextAssignmentsEditor';
import { FeatureInstancePicker } from './FeatureInstancePicker';
import { UserFileFolderPicker } from './UserFileFolderPicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
import { getApiBaseUrl } from '../../../../../config/config';
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
@ -152,8 +164,11 @@ const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
const [loadError, setLoadError] = React.useState<string | null>(null);
const [upstreamBindOptions, setUpstreamBindOptions] = React.useState<Array<{ key: string; label: string; ref: unknown }>>([]);
const autoSingleRef = React.useRef(false);
const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
React.useEffect(() => {
if (!instanceId || !request) return;
@ -170,11 +185,72 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
setLoadError(err instanceof Error ? err.message : String(err));
});
}, [instanceId, request, authority]);
React.useEffect(() => {
if (!instanceId || !request || !dataFlow?.currentNodeId) {
setUpstreamBindOptions([]);
return;
}
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
.then(({ paths }) => {
const opts = paths
.filter(
(p) =>
p.path.length > 0
&& (String(p.path[p.path.length - 1]) === 'id' || p.path.join('.').includes('connection')),
)
.map((p, i) => ({
key: `${p.producerNodeId}:${p.path.join('.')}:${i}`,
label: `${p.producerLabel ?? p.producerNodeId}${p.label}`,
ref: {
type: 'ref',
nodeId: p.producerNodeId,
path: p.path,
expectedType: p.type,
},
}));
setUpstreamBindOptions(opts);
})
.catch(() => setUpstreamBindOptions([]));
}, [instanceId, request, dataFlow?.currentNodeId, dataFlow?.nodes, dataFlow?.connections]);
React.useEffect(() => {
if (connections.length !== 1 || autoSingleRef.current) return;
if (value !== '' && value !== undefined && value !== null) return;
autoSingleRef.current = true;
onChange(connections[0].id);
}, [connections, value, onChange]);
const strVal = typeof value === 'string' ? value : '';
const isRef = typeof value === 'object' && value !== null && (value as { type?: string }).type === 'ref';
const selectedUpstreamKey =
isRef
? upstreamBindOptions.find((o) => {
const r = o.ref as { nodeId?: string; path?: unknown[] };
const v = value as { nodeId?: string; path?: unknown[] };
return r.nodeId === v.nodeId && JSON.stringify(r.path ?? []) === JSON.stringify(v.path ?? []);
})?.key ?? ''
: '';
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{connections.length === 0 && !loadError && (
<div style={{ fontSize: 11, color: '#888', marginBottom: 4 }}>
{authority
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
</div>
)}
{connections.length === 1 && (
<div style={{ fontSize: 12, marginBottom: 4, color: '#444' }}>
{connections[0].label}
</div>
)}
{connections.length > 1 && (
<select
value={typeof value === 'string' ? value : ''}
value={strVal}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
@ -183,11 +259,24 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
{!loadError && connections.length === 0 && (
<div style={{ fontSize: 11, color: '#888', marginTop: 2 }}>
{authority
? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
: t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
)}
{upstreamBindOptions.length > 0 && (
<div style={{ marginTop: 6 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 2 }}>{t('Oder aus vorherigem Node (DataRef)')}</div>
<select
value={selectedUpstreamKey}
onChange={(e) => {
const opt = upstreamBindOptions.find((o) => o.key === e.target.value);
if (opt) onChange(opt.ref);
else if (!e.target.value) onChange('');
}}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('—')}</option>
{upstreamBindOptions.map((o) => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
</div>
)}
{loadError && (
@ -449,6 +538,10 @@ const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
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 = Array.isArray(value) ? value : [];
const addField = () => onChange([...fields, { name: '', type: 'text', label: '', required: false }]);
const removeField = (idx: number) => onChange(fields.filter((_: unknown, i: number) => i !== idx));
@ -457,28 +550,121 @@ const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChan
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
const inputStyle: React.CSSProperties = {
width: '100%', padding: '5px 7px', borderRadius: 4, border: '1px solid #ddd',
fontSize: 12, boxSizing: 'border-box', background: '#fff',
};
const selectStyle: React.CSSProperties = { ...inputStyle };
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<label style={{ display: 'block', fontSize: 12, marginBottom: 4, fontWeight: 600 }}>{param.description || param.name}</label>
{fields.map((f: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4, alignItems: 'center' }}>
<input type="text" placeholder={t('Name')} value={String(f.name ?? '')} onChange={(e) => updateField(i, 'name', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="date">{t('Datum')}</option>
<option value="checkbox">{t('Kontrollkästchen')}</option>
<option value="select">{t('Auswahl')}</option>
<option value="textarea">{t('Mehrzeilig')}</option>
<div key={i} style={{ background: '#f9f9f9', border: '1px solid #e0e0e0', borderRadius: 6, padding: '8px 10px', marginBottom: 6 }}>
{/* Row 1: Bezeichnung + delete */}
<div style={{ display: 'flex', gap: 6, marginBottom: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Bezeichnung (Anzeigename)')}
value={String(f.label ?? '')}
onChange={(e) => updateField(i, 'label', e.target.value)}
style={{ ...inputStyle, flex: 1, fontWeight: 500 }}
/>
<button
type="button"
onClick={() => removeField(i)}
title={t('Feld entfernen')}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', fontSize: 13, lineHeight: 1, flexShrink: 0 }}
>×</button>
</div>
{/* Row 2: Name + Typ + Pflicht */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 6, alignItems: 'end' }}>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Name (intern)</div>
<input
type="text"
placeholder="z.B. customerName"
value={String(f.name ?? '')}
onChange={(e) => updateField(i, 'name', e.target.value)}
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 10, color: '#888', marginBottom: 2 }}>Typ</div>
<select value={String(f.type ?? 'text')} onChange={(e) => updateField(i, 'type', e.target.value)} style={selectStyle}>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
<option value="group">{t('Gruppe')}</option>
</select>
<input type="text" placeholder={t('Bezeichnung')} value={String(f.label ?? '')} onChange={(e) => updateField(i, 'label', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} /> {t('Pflicht')}
</div>
<label style={{ fontSize: 11, display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', paddingBottom: 5, whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={Boolean(f.required)} onChange={(e) => updateField(i, 'required', e.target.checked)} />
Pflicht
</label>
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
{String(f.type) === 'group' && (
<div style={{ marginTop: 8, borderTop: '1px solid #e0e0e0', paddingTop: 8 }}>
<div style={{ fontSize: 11, color: '#666', marginBottom: 6, fontWeight: 600 }}>{t('Unterfelder')}</div>
{(Array.isArray(f.fields) ? f.fields : []).map((sub: Record<string, unknown>, j: number) => (
<div key={j} style={{ background: '#fff', border: '1px solid #e8e8e8', borderRadius: 4, padding: '6px 8px', marginBottom: 4 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder={t('Name')}
value={String(sub.name ?? '')}
onChange={(e) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, name: e.target.value };
updateField(i, 'fields', nextFields);
}}
style={{ ...inputStyle, flex: 1 }}
/>
<select
value={String(sub.type ?? 'text')}
onChange={(e) => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : [])];
nextFields[j] = { ...sub, type: e.target.value };
updateField(i, 'fields', nextFields);
}}
style={{ ...selectStyle, flex: 1 }}
>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
</select>
<button
type="button"
onClick={() => {
const nextFields = (Array.isArray(f.fields) ? f.fields : []).filter((_: unknown, k: number) => k !== j);
updateField(i, 'fields', nextFields);
}}
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', background: '#fff', color: '#999', flexShrink: 0 }}
>×</button>
</div>
</div>
))}
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
<button
type="button"
onClick={() => {
const nextFields = [...(Array.isArray(f.fields) ? f.fields : []), { name: '', type: 'text', label: '', required: false }];
updateField(i, 'fields', nextFields);
}}
style={{ marginTop: 4, padding: '3px 10px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 11, background: '#fff', color: '#666' }}
>
+ {t('Unterfeld hinzufügen')}
</button>
</div>
)}
</div>
))}
<button
type="button"
onClick={addField}
style={{ width: '100%', padding: '6px', borderRadius: 4, border: '1px dashed #bbb', cursor: 'pointer', fontSize: 12, background: '#fff', color: '#555' }}
>
+ {t('Feld hinzufügen')}
</button>
</div>
);
};
@ -601,6 +787,113 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
);
};
const ModelMultiSelect: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const [models, setModels] = React.useState<Array<{ displayName: string; connectorType?: string }>>([]);
const [loading, setLoading] = React.useState(false);
const [open, setOpen] = React.useState(false);
const selected: string[] = Array.isArray(value) ? value : [];
React.useEffect(() => {
let cancelled = false;
setLoading(true);
fetch(`${getApiBaseUrl()}/api/system/ai-models`, { credentials: 'include' })
.then((r) => r.json())
.then((data) => {
if (cancelled) return;
const items = (data?.models ?? []) as Array<{ displayName: string; connectorType?: string }>;
setModels(items);
})
.catch(() => { if (!cancelled) setModels([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []);
const _toggle = (name: string) => {
const next = selected.includes(name)
? selected.filter((v) => v !== name)
: [...selected, name];
onChange(next);
};
const _removeTag = (name: string) => {
onChange(selected.filter((v) => v !== name));
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div
onClick={() => setOpen((o) => !o)}
style={{
width: '100%',
minHeight: 32,
padding: '4px 8px',
borderRadius: 4,
border: '1px solid #ccc',
cursor: 'pointer',
display: 'flex',
flexWrap: 'wrap',
gap: 4,
alignItems: 'center',
background: '#fff',
}}
>
{selected.length === 0 && (
<span style={{ color: '#999', fontSize: 12 }}>{t('Alle erlaubten Modelle')}</span>
)}
{selected.map((name) => (
<span
key={name}
style={{
background: 'var(--primary-color, #2563eb)',
color: '#fff',
borderRadius: 3,
padding: '1px 6px',
fontSize: 11,
display: 'inline-flex',
alignItems: 'center',
gap: 3,
}}
>
{name}
<span
onClick={(e) => { e.stopPropagation(); _removeTag(name); }}
style={{ cursor: 'pointer', fontWeight: 700 }}
>
x
</span>
</span>
))}
</div>
{open && (
<div style={{ border: '1px solid #ddd', borderRadius: 4, marginTop: 4, maxHeight: 200, overflow: 'auto', background: '#fafafa', padding: 4 }}>
{loading && <div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Lade Modelle...')}</div>}
{!loading && models.length === 0 && (
<div style={{ fontSize: 11, color: '#888', padding: 4 }}>{t('Keine Modelle verfügbar')}</div>
)}
{models.map((m) => (
<label
key={m.displayName}
style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '3px 4px', fontSize: 12, cursor: 'pointer' }}
>
<input
type="checkbox"
checked={selected.includes(m.displayName)}
onChange={() => _toggle(m.displayName)}
/>
<span>{m.displayName}</span>
{m.connectorType && (
<span style={{ fontSize: 10, color: '#888' }}>({m.connectorType})</span>
)}
</label>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
@ -608,6 +901,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
text: TextInput,
textarea: TextareaInput,
templateTextarea: TemplateTextareaRenderer,
number: NumberInput,
checkbox: CheckboxInput,
date: DateInput,
@ -615,12 +909,18 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
email: TextInput,
select: SelectInput,
multiselect: MultiSelectInput,
modelMultiSelect: ModelMultiSelect,
json: JsonEditor,
file: TextInput,
hidden: HiddenInput,
dataRef: DataRefRenderer,
contextBuilder: ContextBuilderRenderer,
contextAssignments: ContextAssignmentsEditor,
userConnection: ConnectionPicker,
featureInstance: FeatureInstancePicker,
sharepointFolder: SharepointPathPicker,
sharepointFile: SharepointPathPicker,
userFileFolder: UserFileFolderPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
@ -630,6 +930,7 @@ export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
condition: ConditionBuilder,
mappingTable: MappingTableEditor,
filterExpression: FilterExpressionEditor,
attachmentBuilder: JsonEditor,
};
export default FRONTEND_TYPE_RENDERERS;

View file

@ -0,0 +1,194 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.2 / A1.3
// T7: DataPicker strict-type filtering (only compatible candidates rendered).
// T8: DataPicker generic object drill-down via wildcard '*' segment when the
// schema declares List[X] of a known X.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
import { DataPicker } from './DataPicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
_field('meta', 'str'),
],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.filter((n) => n.id !== opts.consumerNodeId).map((n) => n.id),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
function _renderPicker(props?: { expectedParamType?: string; onPick?: (r: DataRef | SystemVarRef) => void }) {
const upstream = _node('up', 'sharepoint.readDocs');
const consumer = _node('cons', 'ai.summarize');
_setContext({
consumerNodeId: 'cons',
nodes: [upstream, consumer],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [_nodeType('sharepoint.readDocs', 'DocumentList'), _nodeType('ai.summarize', 'AiResult')],
});
return render(
<DataPicker
open
onClose={() => {}}
onPick={props?.onPick ?? (() => {})}
availableSourceIds={['up']}
nodes={[upstream]}
nodeOutputsPreview={{}}
getNodeLabel={(n) => n.title ?? n.id}
expectedParamType={props?.expectedParamType}
/>,
);
}
// ---------------------------------------------------------------------------
// T8: Wildcard drill-down
// ---------------------------------------------------------------------------
describe('DataPicker — generic-object drill-down (T8)', () => {
it('renders the wildcard "documents * name" path when drilling into List[UdmDocument]', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
expect(screen.getByText(/documents \* mimeType/)).toBeInTheDocument();
});
it('lists the wholeOutput, top-level fields, and drilled fields together', async () => {
_renderPicker();
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
// Multiple drilled candidates exist (name, mimeType, sizeBytes, _success, _error).
expect(screen.getAllByText(/documents \*/).length).toBeGreaterThanOrEqual(2);
});
});
// ---------------------------------------------------------------------------
// T7: Strict type filter
// ---------------------------------------------------------------------------
describe('DataPicker — strict type filtering (T7)', () => {
it('hides hard-mismatch fields when expectedParamType is set + strict toggle is on (default)', async () => {
_renderPicker({ expectedParamType: 'str' });
expect(screen.getByLabelText(/Nur kompatible/i)).toBeChecked();
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is a hard mismatch → shown with warning (not removed).
expect(screen.getByText('documents')).toBeInTheDocument();
// meta (str) is exact match → kept.
expect(screen.getByText('meta')).toBeInTheDocument();
// count (int) is "coerce" against str → kept (coerce is allowed in strict mode).
expect(screen.getByText('count')).toBeInTheDocument();
// Drilled wildcard candidates of type str (name, mimeType) remain.
expect(screen.getByText(/documents \* name/)).toBeInTheDocument();
});
it('shows all fields after the user disables the strict toggle', async () => {
_renderPicker({ expectedParamType: 'str' });
await userEvent.click(screen.getByLabelText(/Nur kompatible/i));
await userEvent.click(screen.getByText(/^up$/));
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('count')).toBeInTheDocument();
expect(screen.getByText('meta')).toBeInTheDocument();
});
it('shows the iterieren-button on List[X] candidates that match expectedParamType=X (T6)', async () => {
_renderPicker({ expectedParamType: 'UdmDocument' });
await userEvent.click(screen.getByText(/^up$/));
// documents (List[UdmDocument]) is the only candidate with expectedParamType=UdmDocument
expect(screen.getByText('documents')).toBeInTheDocument();
expect(screen.getByText('iterieren')).toBeInTheDocument();
});
it('emits a wildcard ref when the user clicks "iterieren"', async () => {
const onPick = vi.fn();
_renderPicker({ expectedParamType: 'UdmDocument', onPick });
await userEvent.click(screen.getByText(/^up$/));
await userEvent.click(screen.getByText('iterieren'));
expect(onPick).toHaveBeenCalledWith(
expect.objectContaining({
type: 'ref',
nodeId: 'up',
path: ['documents', '*'],
expectedType: 'UdmDocument',
}),
);
});
});

View file

@ -1,14 +1,17 @@
/**
* Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts.
* Builds pickable paths from portTypeCatalog + node outputPorts, or from
* outputPorts[n].dataPickOptions when the backend defines an explicit list (authoritative).
* Resolves Transit chains to show the real upstream schema.
* Includes a System Variables section.
*/
import React, { useState } from 'react';
import { createRef, createSystemVar, type DataRef, type SystemVarRef } from './dataRef';
import React, { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { createRef, createSystemVar, type DataRef, type SystemVarRef, isCompatible } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
import type { DataPickOption, GraphDefinedSchemaRef, NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import { findLoopAncestorIds } from './scopeHelpers';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
@ -18,32 +21,156 @@ interface DataPickerProps {
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
availableSourceIds: string[];
nodes: Array<{ id: string; title?: string; type?: string }>;
nodes: Array<{ id: string; title?: string; type?: string; parameters?: Record<string, unknown> }>;
nodeOutputsPreview: Record<string, unknown>;
getNodeLabel: (node: { id: string; title?: string }) => string;
/** When set, the picker can hide incompatible candidates (strict toggle) and
* surfaces "Iterieren als Loop" affordances for List[X]X candidates. */
expectedParamType?: string;
}
interface PickablePath {
path: (string | number)[];
label: string;
type?: string;
/** True iff this path produces `List[X]` and the consumer expects `X`
* picking with iterate=true appends the wildcard segment. */
iterable?: boolean;
/** Annotated after strict-filter pass: type exists but doesn't match the expected param type. */
typeMismatch?: boolean;
/** Surfaced at the top of the list as the most common / recommended pick. */
recommended?: boolean;
/** Tooltip (Katalog oder Backend-Hinweistext). */
detail?: string;
}
const _LIST_INNER_RE = /^List\[(.+)\]$/;
function _fieldSegHuman(field: PortField): string {
const picker = field.pickerLabel;
if (typeof picker === 'string' && picker.trim()) return picker.trim();
return field.name;
}
function _detailFromField(description: unknown): string | undefined {
if (typeof description === 'string' && description.trim()) return description.trim();
return undefined;
}
function _buildPathsFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
baseSegments: string[] = [],
depth = 0,
): PickablePath[] {
if (!schema || !schema.fields) return [];
if (!schema || !schema.fields || depth > 8) return [];
const result: PickablePath[] = [];
for (const field of schema.fields) {
const fieldPath = [...basePath, field.name];
const label = fieldPath.map(String).join(' → ');
result.push({ path: fieldPath, label, type: field.type });
// For form schemas (kind=fromGraph), expose the whole `payload` object as a
// top-level pickable entry so the user can pass the entire form at once.
if (depth === 0 && schema.name?.startsWith('FormPayload')) {
result.push({
path: ['payload'],
label: 'Gesamtes Formular',
type: 'object',
recommended: true,
});
}
result.push({ path: [...basePath, '_success'], label: [...basePath, '_success'].map(String).join(' → '), type: 'bool' });
result.push({ path: [...basePath, '_error'], label: [...basePath, '_error'].map(String).join(' → '), type: 'str' });
for (const field of schema.fields) {
const segHuman = _fieldSegHuman(field);
const fieldPath = [...basePath, field.name];
const label =
baseSegments.length > 0
? `${baseSegments.join(' ')} ${segHuman}`
: segHuman;
const detail = _detailFromField(field.description);
result.push({
path: fieldPath,
label,
type: field.type,
recommended: field.recommended ?? false,
detail,
});
const m = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE) : null;
const inner = m?.[1]?.trim();
if (inner && catalog[inner]) {
const pil = typeof field.pickerItemLabel === 'string' ? field.pickerItemLabel.trim() : '';
const itemBridge = pil || '*';
const nextSegments = [...baseSegments, segHuman, itemBridge];
result.push(..._buildPathsFromSchema(catalog[inner], catalog, [...fieldPath, '*'], nextSegments, depth + 1));
}
}
result.push({
path: [...basePath, '_success'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Erfolgskennzeichen` : '_success',
type: 'bool',
});
result.push({
path: [...basePath, '_error'],
label:
baseSegments.length > 0 ? `${baseSegments.join(' ')} Fehlermeldung` : '_error',
type: 'str',
});
return result;
}
/** Annotate each candidate with `iterable=true` if it is `List[X]` and the
* consumer expects `X`. Used to render a "Iterieren als Loop"-Vorschlag. */
function _markIterableCandidates(paths: PickablePath[], expectedParamType?: string): PickablePath[] {
if (!expectedParamType) return paths;
return paths.map((p) => {
if (!p.type) return p;
const m = p.type.match(_LIST_INNER_RE);
if (m && m[1].trim() === expectedParamType) return { ...p, iterable: true };
return p;
});
}
function _deriveFormPortSchemaFromParams(
node: { parameters?: Record<string, unknown> },
paramKey: string,
formTypeToPort: Record<string, string> = {},
): PortSchema | undefined {
const resolvePortType = (rawType: string) => formTypeToPort[rawType] ?? rawType;
const raw = node.parameters?.[paramKey];
if (!Array.isArray(raw)) return undefined;
const fields: Array<{ name: string; type: string; description: string | Record<string, string>; required: boolean }> = [];
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;
let description: string | Record<string, string> = rec.name;
if (typeof lab === 'string') description = lab;
else if (lab && typeof lab === 'object') description = lab as Record<string, string>;
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;
let sdesc: string | Record<string, string> = `${rec.name}.${sub.name}`;
if (typeof sl === 'string') sdesc = sl;
else if (sl && typeof sl === 'object') sdesc = sl as Record<string, string>;
fields.push({
name: `${rec.name}.${sub.name}`,
type: resolvePortType(typeof sub.type === 'string' ? sub.type : 'str'),
description: sdesc,
required: Boolean(sub.required),
});
}
continue;
}
fields.push({
name: rec.name,
type: resolvePortType(rawType),
description,
required: Boolean(rec.required),
});
}
return fields.length ? { name: 'FormPayload_dynamic', fields } : undefined;
}
function _buildPathsFromPreview(
obj: unknown,
@ -72,13 +199,26 @@ function _buildPathsFromPreview(
return [{ path: [...basePath], label: pathLabel }];
}
/** Gateway ``outputPorts[n].dataPickOptions`` — authoritative; no client-side catalog merge. */
function _pathsFromDataPickOptions(options: DataPickOption[]): PickablePath[] {
return options.map((o) => ({
path: [...o.path],
label: o.pickerLabel,
type: o.type,
recommended: Boolean(o.recommended),
iterable: Boolean(o.iterable),
detail: typeof o.detail === 'string' ? o.detail.trim() : undefined,
}));
}
function _resolveSchemaForNode(
nodeId: string,
nodes: Array<{ id: string; type?: string }>,
nodes: Array<{ id: string; type?: string; parameters?: Record<string, unknown> }>,
nodeTypes: NodeType[],
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
catalog: Record<string, PortSchema>,
visited: Set<string> = new Set(),
formTypeToPort: Record<string, string> = {},
): PortSchema | undefined {
if (visited.has(nodeId)) return undefined;
visited.add(nodeId);
@ -88,17 +228,29 @@ function _resolveSchemaForNode(
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
if (!typeDef?.outputPorts) return undefined;
const port0 = typeDef.outputPorts[0];
const port0 = typeDef.outputPorts[0] as {
schema?: string | GraphDefinedSchemaRef;
dynamic?: boolean;
deriveFrom?: string;
};
if (!port0) return undefined;
if (port0.schema !== 'Transit') {
return catalog[port0.schema];
const schemaSpec = port0.schema;
if (typeof schemaSpec === 'object' && schemaSpec !== null && schemaSpec.kind === 'fromGraph') {
const paramKey = schemaSpec.parameter ?? 'fields';
return _deriveFormPortSchemaFromParams(node, paramKey, formTypeToPort);
}
if (port0.dynamic && port0.deriveFrom) {
return _deriveFormPortSchemaFromParams(node, port0.deriveFrom, formTypeToPort);
}
if (typeof schemaSpec === 'string' && schemaSpec !== 'Transit') {
return catalog[schemaSpec];
}
// Transit: follow the incoming connection to find the real producer
const incoming = connections.find((c) => c.target === nodeId);
if (!incoming) return undefined;
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited);
return _resolveSchemaForNode(incoming.source, nodes, nodeTypes, connections, catalog, visited, formTypeToPort);
}
export const DataPicker: React.FC<DataPickerProps> = ({ open,
@ -108,23 +260,45 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
nodes,
nodeOutputsPreview,
getNodeLabel,
expectedParamType,
}) => {
const { t } = useLanguage();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showSystem, setShowSystem] = useState(false);
// Default: when the consumer declares an expected type, show only compatible
// candidates ("strict" mode). User can override per-session via the toggle.
const [strictFilter, setStrictFilter] = useState<boolean>(Boolean(expectedParamType));
const ctx = useAutomation2DataFlow();
// NOTE: All hooks must be called unconditionally on every render to satisfy
// the Rules of Hooks. The `if (!open) return null;` early-return therefore
// has to live BELOW every hook in this component. Adding a useMemo (or any
// other hook) below it would change the hook count when the picker toggles
// open/closed and crash the whole tree (white screen).
const connectionsRaw = ctx?.connections ?? [];
const connections = useMemo(
() =>
connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
})),
[connectionsRaw],
);
const loopAncestorIds = useMemo(() => {
const cid = ctx?.currentNodeId;
if (!cid) return [] as string[];
return findLoopAncestorIds(nodes, connections, cid);
}, [ctx?.currentNodeId, nodes, connections]);
if (!open) return null;
const catalog = ctx?.portTypeCatalog ?? {};
const systemVars = ctx?.systemVariables ?? {};
const nodeTypes = ctx?.nodeTypes ?? [];
const connectionsRaw = ctx?.connections ?? [];
const connections = connectionsRaw.map((c) => ({
source: c.sourceId,
target: c.targetId,
sourceOutput: c.sourceHandle,
}));
const formTypeToPort: Record<string, string> = Object.fromEntries(
(ctx?.formFieldTypes ?? []).map((f) => [f.id, f.portType])
);
const toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
@ -135,8 +309,15 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
});
};
const handlePick = (nodeId: string, path: (string | number)[]) => {
onPick(createRef(nodeId, path));
const handlePick = (nodeId: string, path: (string | number)[], expectedType?: string) => {
onPick(createRef(nodeId, path, expectedType));
onClose();
};
/** Loop-Vorschlag: for List[X]X candidates, append the '*' wildcard so the
* engine maps the consumer over each element (executionEngine wildcard). */
const handlePickIterate = (nodeId: string, path: (string | number)[], expectedType?: string) => {
onPick(createRef(nodeId, [...path, '*'], expectedType));
onClose();
};
@ -145,17 +326,106 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClose();
};
return (
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
const _dialog = (
<div
className={styles.dataPickerOverlay}
onClick={onClose}
onKeyDown={(e) => e.key === 'Escape' && onClose()}
role="presentation"
>
<div
className={styles.dataPickerModal}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="automation2DataPickerTitle"
>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
<h4 className={styles.dataPickerTitle} id="automation2DataPickerTitle">
{t('Datenquelle wählen')}
{expectedParamType && (
<span
className={styles.dataPickerTypeBadge}
title={t('Erwarteter Typ')}
>
{expectedParamType}
</span>
)}
</h4>
<div className={styles.dataPickerHeaderControls}>
{expectedParamType && (
<label className={styles.dataPickerStrictLabel}>
<input
type="checkbox"
checked={strictFilter}
onChange={(e) => setStrictFilter(e.target.checked)}
/>
{t('Nur kompatible')}
</label>
)}
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
×
</button>
</div>
</div>
<div className={styles.dataPickerBody}>
{/* System Variables Section */}
{loopAncestorIds.length > 0 && (
<div className={styles.dataPickerNodeSection}>
<div className={styles.dataPickerNodeHeader} style={{ cursor: 'default' }}>
<span className={styles.dataPickerNodeLabel}>{t('Schleife (lexikalisch)')}</span>
</div>
<div className={styles.dataPickerTree}>
{loopAncestorIds.map((loopId) => {
const loopNode = nodes.find((n) => n.id === loopId);
const loopLabel = loopNode ? getNodeLabel(loopNode as { id: string; title?: string }) : loopId;
const loopSchema = catalog.LoopItem;
const loopPaths = loopSchema
? _buildPathsFromSchema(loopSchema, catalog, [], [], 0).filter((p) => !String(p.path[p.path.length - 1]).startsWith('_'))
: [
{ path: ['currentItem'], label: 'currentItem', type: 'Any' },
{ path: ['currentIndex'], label: 'currentIndex', type: 'int' },
{ path: ['count'], label: 'count', type: 'int' },
];
return (
<div key={loopId} style={{ marginBottom: 6 }}>
<div style={{ fontSize: 11, color: 'var(--text-secondary)', marginBottom: 2 }}>{loopLabel}</div>
{loopPaths.map((p, i) => {
const mismatch =
Boolean(expectedParamType) &&
Boolean(p.type) &&
isCompatible(p.type!, expectedParamType!) === 'mismatch';
return (
<button
key={`${loopId}-${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(loopId, p.path, p.type)}
>
{p.label}
{p.type && (
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{mismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
</div>
)}
{Object.keys(systemVars).length > 0 && (
<div className={styles.dataPickerNodeSection}>
<button
@ -176,7 +446,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => handlePickSystemVar(key)}
title={info.description}
>
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
{key} <span className={styles.dataPickerLeafType}>({info.type})</span>
</button>
))}
</div>
@ -195,16 +465,53 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
const label = node ? getNodeLabel(node) : nodeId;
// User-defined step title (or node-type label as fallback)
const stepTitle = node ? getNodeLabel(node) : nodeId;
const nodeTypeDef = node?.type ? nodeTypes.find((nt) => nt.id === node.type) : undefined;
// Human-readable type label (e.g. "Formular", "Web-Recherche")
const typeLabel = nodeTypeDef?.label ?? node?.type ?? '';
const isExpanded = expandedNodes.has(nodeId);
const port0Def = nodeTypeDef?.outputPorts?.[0];
const backendPick =
port0Def?.dataPickOptions &&
Array.isArray(port0Def.dataPickOptions) &&
port0Def.dataPickOptions.length > 0;
let schemaPaths: PickablePath[];
if (backendPick) {
schemaPaths = _pathsFromDataPickOptions(port0Def!.dataPickOptions!);
} else {
const resolvedSchema = _resolveSchemaForNode(
nodeId, nodes, nodeTypes, connections, catalog,
nodeId,
nodes,
nodeTypes,
connections,
catalog,
new Set(),
formTypeToPort,
);
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
const paths = schemaPaths.length > 0
schemaPaths = _buildPathsFromSchema(resolvedSchema, catalog);
}
const annotated = _markIterableCandidates(
schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)')),
expectedParamType,
);
const markedPaths = annotated.map((p) => ({
...p,
typeMismatch:
strictFilter &&
Boolean(expectedParamType) &&
Boolean(p.type) &&
!p.iterable &&
isCompatible(p.type!, expectedParamType!) === 'mismatch',
}));
const orderedPaths = [
...markedPaths.filter((p) => p.recommended),
...markedPaths.filter((p) => !p.recommended),
];
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>
@ -214,29 +521,63 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span>
{resolvedSchema && (
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
({resolvedSchema.name})
<span className={styles.dataPickerNodeLabel}>{stepTitle}</span>
{typeLabel && (
<span className={styles.dataPickerNodeSchemaHint}>
{typeLabel}
</span>
)}
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.map((p, i) => (
<button
{orderedPaths.length === 0 && (
<div style={{ fontSize: 11, color: 'var(--text-secondary)', padding: '4px 8px' }}>
{t('(keine Felder verfügbar)')}
</div>
)}
{orderedPaths.map((p, i) => (
<div
key={`${p.path.join('.')}-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 4 }}
>
<button
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(nodeId, p.path)}
className={`${styles.dataPickerLeaf}${p.recommended ? ` ${styles.dataPickerLeafRecommended}` : ''}`}
style={{ flex: 1 }}
onClick={() => handlePick(nodeId, p.path, p.type)}
title={p.detail || p.label}
>
{p.label}
{p.recommended && (
<span className={styles.dataPickerRecommendedPill}>
{t('Empfohlen')}
</span>
)}
{p.type && (
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
<span className={styles.dataPickerLeafType}>
({p.type})
</span>
)}
{p.typeMismatch && (
<span
className={styles.dataPickerMismatchBadge}
title={t('Typ weicht ab — wird beim Ausführen konvertiert')}
>
</span>
)}
</button>
{p.iterable && (
<button
type="button"
className={`${styles.dataPickerLeaf} ${styles.dataPickerIterateBtn}`}
onClick={() => handlePickIterate(nodeId, p.path, expectedParamType)}
title={t('Pro Element der Liste iterieren (Loop)')}
>
{t('iterieren')}
</button>
)}
</div>
))}
</div>
)}
@ -248,4 +589,6 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
</div>
</div>
);
return createPortal(_dialog, document.body);
};

View file

@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
return out;
}
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['currentItem'], pathLabel: 'currentItem' },
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
{ path: ['count'], pathLabel: 'count' },
];
if (preview && typeof preview === 'object') {
const ci = (preview as Record<string, unknown>).currentItem;
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
paths.push(...buildPickablePaths(v, ['currentItem', k]));
}
}
}
return paths;
}
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths = buildPickablePaths(preview);
if (preview && typeof preview === 'object') {
const rd = (preview as Record<string, unknown>).responseData;
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
for (const k of Object.keys(rd as Record<string, unknown>)) {
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
}
}
}
return paths;
}
export function pickPathsForNode(
node: { type?: string; parameters?: Record<string, unknown> } | undefined,
preview: unknown,
@ -113,6 +144,12 @@ export function pickPathsForNode(
if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview);
}
if (nt === 'flow.loop') {
return buildLoopCurrentItemPaths(preview);
}
if (nt === 'ai.prompt') {
return buildAiPromptPaths(preview);
}
return buildPickablePaths(preview);
}
@ -358,8 +395,6 @@ function getFormFieldType(
if (rawFieldType === 'email') return 'email';
if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
if (rawFieldType === 'clickup_tasks') return 'string';
if (rawFieldType === 'clickup_status') return 'string';
return 'string';
}

View file

@ -0,0 +1,243 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1.1: Component-level tests for RequiredAttributePicker.
// Validates the 0/1/N rendering logic that orchestrates DataPicker selection
// + the iterierens-suggestion (T5, T6).
//
// We mock the two consumed contexts (LanguageContext + Automation2DataFlow)
// and the DataPicker child so we can assert on the picker UI in isolation.
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
import type { DataRef, SystemVarRef } from './dataRef';
// ---------------------------------------------------------------------------
// Module mocks — must be registered before importing the SUT
// ---------------------------------------------------------------------------
vi.mock('../../../../providers/language/LanguageContext', () => ({
useLanguage: () => ({ t: (s: string) => s }),
}));
let _ctxValue: unknown = null;
vi.mock('../../context/Automation2DataFlowContext', () => ({
useAutomation2DataFlow: () => _ctxValue,
}));
vi.mock('./DataPicker', () => ({
DataPicker: (props: {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
}) => {
if (!props.open) return null;
return (
<div data-testid="mock-data-picker">
<button
type="button"
onClick={() => {
props.onPick({ type: 'ref', nodeId: 'picked', path: [], expectedType: 'DocumentList' });
props.onClose();
}}
>
mock-pick
</button>
<button type="button" onClick={props.onClose}>
mock-close
</button>
</div>
);
},
}));
// SUT imported AFTER mocks (so mocks are applied)
import { RequiredAttributePicker } from './RequiredAttributePicker';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
const _docListSchema: PortSchema = {
name: 'DocumentList',
fields: [_field('documents', 'List[UdmDocument]'), _field('count', 'int')],
};
const _udmDocumentSchema: PortSchema = {
name: 'UdmDocument',
fields: [_field('name', 'str'), _field('mimeType', 'str')],
};
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
};
function _setContext(opts: {
consumerNodeId: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
}) {
_ctxValue = {
currentNodeId: opts.consumerNodeId,
nodes: opts.nodes,
connections: opts.connections,
nodeTypes: opts.nodeTypes,
portTypeCatalog: _portCatalog,
nodeOutputsPreview: {},
systemVariables: {},
language: 'de',
getNodeLabel: (n: { id: string; title?: string }) => n.title ?? n.id,
getAvailableSourceIds: () => opts.nodes.map((n) => n.id).filter((id) => id !== opts.consumerNodeId),
parseGraphDefinedSchema: () => null,
};
}
function _node(id: string, type: string): CanvasNode {
return { id, type, title: id, x: 0, y: 0, inputs: 1, outputs: 1, parameters: {} };
}
function _conn(id: string, src: string, tgt: string): CanvasConnection {
return { id, sourceId: src, sourceHandle: 0, targetId: tgt, targetHandle: 0 };
}
function _nodeType(id: string, outputSchema: string): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters: [],
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RequiredAttributePicker — 0/1/N rendering (T5/T6)', () => {
it('shows red "no source" pill when no upstream candidate matches (0-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('cons', 'ai.summarizeDocument')],
connections: [],
nodeTypes: [_nodeType('ai.summarizeDocument', 'AiResult')],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(
screen.getByText(/Keine typkompatible Quelle vorhanden/i),
).toBeInTheDocument();
});
it('shows auto-bind suggestion when exactly one candidate matches (1-case)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/Vorschlag übernehmen/i)).toBeInTheDocument();
});
it('shows iterieren-suggestion when upstream is List[X] and required is X (T6)', () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
render(
<RequiredAttributePicker
label="Single document"
expectedType="UdmDocument"
value={undefined}
onChange={() => {}}
/>,
);
expect(screen.getByText(/iterieren/i)).toBeInTheDocument();
});
it('renders bound chip + "Andere wählen" when value is already a DataRef', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
expect(screen.getByText('up')).toBeInTheDocument();
const clearButton = screen.getByTitle(/Bindung entfernen/i);
await userEvent.click(clearButton);
expect(onChange).toHaveBeenCalledWith(null);
});
it('opens DataPicker via "Andere wählen" and forwards the picked ref to onChange', async () => {
_setContext({
consumerNodeId: 'cons',
nodes: [_node('up', 'sharepoint.readDocs'), _node('cons', 'ai.summarizeDocument')],
connections: [_conn('c1', 'up', 'cons')],
nodeTypes: [
_nodeType('sharepoint.readDocs', 'DocumentList'),
_nodeType('ai.summarizeDocument', 'AiResult'),
],
});
const onChange = vi.fn();
render(
<RequiredAttributePicker
label="Document List"
expectedType="DocumentList"
value={{ type: 'ref', nodeId: 'up', path: [], expectedType: 'DocumentList' }}
onChange={onChange}
/>,
);
const otherButton = screen.getByText(/Andere wählen…/i);
await userEvent.click(otherButton);
expect(screen.getByTestId('mock-data-picker')).toBeInTheDocument();
await userEvent.click(screen.getByText('mock-pick'));
expect(onChange).toHaveBeenCalledWith(
expect.objectContaining({ type: 'ref', nodeId: 'picked', expectedType: 'DocumentList' }),
);
});
});

View file

@ -0,0 +1,282 @@
/**
* RequiredAttributePicker Phase-4 Schicht-4 binding affordance for
* required parameters of a Schicht-3 Adapter (Editor-Node).
*
* 0/1/N logic, applied on the set of typed source candidates:
* - 0 candidates red pill: "Keine typkompatible Quelle vorhanden"
* (user must add an upstream node first)
* - 1 candidate auto-bound chip with a "Andere wählen…" override button
* (still shown explicitly so the user sees what was chosen)
* - N candidates "Quelle wählen…" button that opens the DataPicker
* pre-filtered to the expected type
*
* The picker also surfaces a "Iterieren als Loop" hint when the expected type
* is `X` and an upstream candidate is `List[X]` see paramValidation.ts.
*/
import React, { useMemo, useState } from 'react';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from './DataPicker';
import { createRef, formatRefLabel, isRef, type DataRef, type SystemVarRef } from './dataRef';
import { findSourceCandidates, strictlyCompatible, type SourceCandidate } from './paramValidation';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface RequiredAttributePickerProps {
/** Display label for the parameter (already localized). */
label: string;
/** Type expected by the bound action argument (e.g. "DocumentList", "str"). */
expectedType?: string;
/** Current bound value (DataRef, SystemVarRef, or unset). */
value: unknown;
/** Persist a new binding (or `null` to clear). */
onChange: (next: DataRef | SystemVarRef | null) => void;
/** Optional description shown beneath the picker. */
description?: React.ReactNode;
}
export const RequiredAttributePicker: React.FC<RequiredAttributePickerProps> = ({
label,
expectedType,
value,
onChange,
description,
}) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = useState(false);
const consumerNodeId = ctx?.currentNodeId ?? '';
const nodes = ctx?.nodes ?? [];
const connections = ctx?.connections ?? [];
const nodeTypes = ctx?.nodeTypes ?? [];
const catalog = ctx?.portTypeCatalog ?? {};
const allCandidates: SourceCandidate[] = useMemo(() => {
if (!consumerNodeId) return [];
return findSourceCandidates({
consumerNodeId,
expectedType,
nodes,
connections: connections.map((c) => ({
id: c.id,
sourceId: c.sourceId,
sourceHandle: c.sourceHandle,
targetId: c.targetId,
targetHandle: c.targetHandle,
})),
nodeTypes,
portTypeCatalog: catalog,
});
}, [consumerNodeId, expectedType, nodes, connections, nodeTypes, catalog]);
const compatibleCandidates = useMemo(() => strictlyCompatible(allCandidates), [allCandidates]);
const isBoundRef = isRef(value);
const boundLabel = isBoundRef ? formatRefLabel(value as DataRef, nodes) : null;
// 0/1/N
const candidateCount = compatibleCandidates.length;
const single = candidateCount === 1 ? compatibleCandidates[0] : null;
const handleAutoBind = () => {
if (!single) return;
const ref = createRef(single.nodeId, single.iterable && expectedType ? [...single.path, '*'] : single.path, expectedType);
onChange(ref);
};
const handlePicked = (picked: DataRef | SystemVarRef) => {
onChange(picked);
};
return (
<div
className={styles.requiredAttributePicker}
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
minWidth: 0,
maxWidth: '100%',
}}
>
{/* Header: label always takes the full row (flex-basis 100 %), badge
wraps below prevents long type names like List[ActionDocument]
from escaping the panel frame on the right. */}
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
<label
style={{
fontSize: 12,
fontWeight: 600,
flex: '1 1 100%',
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{label}
<span style={{ color: 'var(--danger-color, #dc3545)', marginLeft: 2 }}>*</span>
</label>
{expectedType && (
<span
title={t('Erwarteter Typ')}
style={{
fontSize: 10,
fontFamily: 'monospace',
color: 'var(--text-secondary, #555)',
background: 'var(--bg-secondary, #eee)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 4,
padding: '1px 6px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{expectedType}
</span>
)}
</div>
{isBoundRef ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<span
title={typeof boundLabel === 'string' ? boundLabel : undefined}
style={{
padding: '2px 8px',
borderRadius: 12,
background: 'rgba(40,167,69,0.15)',
color: 'var(--success-color, #28a745)',
fontSize: 12,
fontWeight: 500,
maxWidth: '100%',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
>
{boundLabel}
</span>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
>
{t('Andere wählen…')}
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => onChange(null)}
style={{ fontSize: 11, padding: '2px 8px', flexShrink: 0 }}
title={t('Bindung entfernen')}
>
×
</button>
</div>
) : candidateCount === 0 ? (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 6,
padding: '4px 8px',
background: 'rgba(220,53,69,0.12)',
color: 'var(--danger-color, #dc3545)',
borderRadius: 6,
fontSize: 12,
minWidth: 0,
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
<span aria-hidden="true" style={{ flexShrink: 0 }}></span>
<span style={{ minWidth: 0 }}>
{t('Keine typkompatible Quelle vorhanden — füge zuerst einen Knoten ein, der ')}
<code style={{ fontFamily: 'monospace', overflowWrap: 'anywhere' }}>{expectedType ?? '?'}</code>
{t(' liefert.')}
</span>
</div>
) : single ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={handleAutoBind}
style={{
fontSize: 11,
padding: '3px 10px',
maxWidth: '100%',
whiteSpace: 'normal',
textAlign: 'left',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
lineHeight: 1.4,
}}
title={t('Einzige passende Quelle übernehmen')}
>
{t('Vorschlag übernehmen:')}{' '}
<strong>
{nodes.find((n) => n.id === single.nodeId)?.title ?? single.nodeId}
{single.path.length > 0 ? ' → ' + single.path.map(String).join(' → ') : ''}
{single.iterable ? ' [' + t('iterieren') + ']' : ''}
</strong>
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', flexShrink: 0 }}
>
{t('Andere…')}
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap', minWidth: 0 }}>
<button
type="button"
className={styles.retryButton}
onClick={() => setPickerOpen(true)}
style={{ fontSize: 11, padding: '3px 10px', maxWidth: '100%' }}
>
{t('Quelle wählen…')} <span style={{ opacity: 0.6 }}>({candidateCount})</span>
</button>
</div>
)}
{description && (
<div
style={{
fontSize: 11,
color: 'var(--text-tertiary, #888)',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
}}
>
{description}
</div>
)}
{pickerOpen && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={(picked) => {
handlePicked(picked);
setPickerOpen(false);
}}
availableSourceIds={ctx?.getAvailableSourceIds() ?? []}
nodes={nodes}
nodeOutputsPreview={ctx?.nodeOutputsPreview ?? {}}
getNodeLabel={(n) =>
ctx?.getNodeLabel(n as { id: string; title?: string; label?: string; type?: string }) ?? n.id
}
expectedParamType={expectedType}
/>
)}
</div>
);
};

View file

@ -1,294 +0,0 @@
/**
* Sync input.form / trigger.form fields + ClickUp "Aufgabe erstellen" refs from a selected ClickUp list.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { FormField } from './types';
import { createRef } from './dataRef';
export type ClickUpFieldLike = Record<string, unknown>;
function buildReverseAdjacency(connections: CanvasConnection[]): Record<string, string[]> {
const rev: Record<string, string[]> = {};
for (const c of connections) {
if (!rev[c.targetId]) rev[c.targetId] = [];
rev[c.targetId].push(c.sourceId);
}
return rev;
}
/** Nearest form node upstream (toward triggers) of the ClickUp node. */
export function findClosestUpstreamFormNode(
targetNodeId: string,
nodes: CanvasNode[],
connections: CanvasConnection[]
): CanvasNode | null {
const nodeById = new Map(nodes.map((n) => [n.id, n]));
const rev = buildReverseAdjacency(connections);
const queue: string[] = [...(rev[targetNodeId] ?? [])];
const visited = new Set<string>();
while (queue.length > 0) {
const nid = queue.shift()!;
if (visited.has(nid)) continue;
visited.add(nid);
const n = nodeById.get(nid);
if (!n) continue;
if (n.type === 'input.form' || n.type === 'trigger.form') return n;
for (const p of rev[nid] ?? []) {
if (!visited.has(p)) queue.push(p);
}
}
return null;
}
export function normalizeClickUpFieldType(raw: unknown): string {
return String(raw ?? 'short_text')
.trim()
.toLowerCase()
.replace(/-/g, '_')
.replace(/\s+/g, '_');
}
function linkedListIdFromRelationshipField(field: ClickUpFieldLike): string | null {
const tc = (field.type_config ?? {}) as Record<string, unknown>;
const asId = (v: unknown): string | null => {
if (typeof v === 'string' && v.trim()) return v.trim();
if (typeof v === 'number' && Number.isFinite(v)) return String(v);
return null;
};
const keys = [
'linked_list_id',
'list_id',
'related_list_id',
'relationship_list_id',
'resource_id',
];
for (const k of keys) {
const raw = tc[k];
const id = asId(raw);
if (id) return id;
if (raw && typeof raw === 'object' && raw !== null) {
const nested = asId((raw as Record<string, unknown>).id);
if (nested) return nested;
}
}
const rel = tc.relationship;
if (rel && typeof rel === 'object' && rel !== null) {
const r = rel as Record<string, unknown>;
const fromRel = asId(r.list_id ?? r.id ?? r.target_id ?? r.linked_list_id ?? r.resource_id);
if (fromRel) return fromRel;
}
return null;
}
function fieldUnsupported(ft: string): boolean {
return ['tasks', 'user', 'users'].includes(ft);
}
function mapCuToInputFormField(
field: ClickUpFieldLike,
connectionId: string,
parentListId: string
): FormField | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
const lid = linkedListIdFromRelationshipField(field) ?? parentListId;
return {
name,
label,
type: 'clickup_tasks',
required: false,
clickupConnectionId: connectionId,
clickupListId: lid,
};
}
if (
ft === 'drop_down' ||
ft === 'dropdown' ||
ft === 'text' ||
ft === 'long_text' ||
ft === 'short_text' ||
ft === 'email' ||
ft === 'phone' ||
ft === 'url'
) {
return { name, label, type: 'string', required: false };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number', required: false };
}
if (ft === 'date') {
return { name, label, type: 'date', required: false };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean', required: false };
}
return { name, label, type: 'string', required: false };
}
/** trigger.form row; `clickup_status` carries options from the same list API as the ClickUp node dropdown. */
export type TriggerFormFieldRow = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
function mapCuToTriggerFormField(field: ClickUpFieldLike, _connectionId: string, _parentListId: string): TriggerFormFieldRow | null {
const fid = String(field.id ?? '');
if (!fid) return null;
const fname = String(field.name ?? fid);
const ft = normalizeClickUpFieldType(field.type);
if (fieldUnsupported(ft)) return null;
const name = `cf_${fid.replace(/[^a-zA-Z0-9_]/g, '_')}`;
const label = fname || name;
if (ft === 'list_relationship') {
return { name, label, type: 'text' };
}
if (ft === 'number' || ft === 'currency') {
return { name, label, type: 'number' };
}
if (ft === 'date') {
return { name, label, type: 'date' };
}
if (ft === 'checkbox') {
return { name, label, type: 'boolean' };
}
if (ft === 'email') {
return { name, label, type: 'email' };
}
return { name, label, type: 'text' };
}
export const PAYLOAD_TITLE = 'title';
export const PAYLOAD_DESCRIPTION = 'description';
export const PAYLOAD_STATUS = 'clickup_status';
export const PAYLOAD_PRIORITY = 'clickup_priority';
export const PAYLOAD_DUE = 'clickup_due_date';
export const PAYLOAD_TIME_H = 'clickup_time_estimate_h';
/** Same ordering as ClickUp list `statuses` (GET /list/{id}). */
export function statusOptionsFromListStatuses(
listStatuses: Array<{ status: string; orderindex: number }>
): Array<{ value: string; label: string }> {
return [...listStatuses]
.sort((a, b) => a.orderindex - b.orderindex)
.map((s) => ({ value: s.status, label: s.status }));
}
export interface SyncFromListResult {
inputFormFields: FormField[];
triggerFormFields: TriggerFormFieldRow[];
clickupPatch: Record<string, unknown>;
}
/**
* Build form field rows + ClickUp createTask parameter patch (refs payload.*).
*/
export function buildSyncFromClickUpList(args: {
formNodeId: string;
listFields: ClickUpFieldLike[];
/** From GET /list/{id} → list.statuses (same source as the ClickUp node status dropdown). */
listStatuses: Array<{ status: string; orderindex: number }>;
connectionId: string;
teamId: string;
listId: string;
}): SyncFromListResult {
const { formNodeId, listFields, listStatuses, connectionId, teamId, listId } = args;
const ref = (key: string) => createRef(formNodeId, ['payload', key]);
const statusOpts = statusOptionsFromListStatuses(listStatuses);
const standardInput: FormField[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'string', required: true },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'string', required: false },
...(statusOpts.length > 0
? [
{
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
required: false,
clickupStatusOptions: statusOpts,
} as FormField,
]
: []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number', required: false },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date', required: false },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number', required: false },
];
const statusTriggerRow: TriggerFormFieldRow | null =
statusOpts.length > 0
? {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
}
: null;
const standardTrigger: TriggerFormFieldRow[] = [
{ name: PAYLOAD_TITLE, label: 'Titel', type: 'text' },
{ name: PAYLOAD_DESCRIPTION, label: 'Beschreibung', type: 'text' },
...(statusTriggerRow ? [statusTriggerRow] : []),
{ name: PAYLOAD_PRIORITY, label: 'Priorität (14)', type: 'number' },
{ name: PAYLOAD_DUE, label: 'Fälligkeit', type: 'date' },
{ name: PAYLOAD_TIME_H, label: 'Zeitschätzung (Stunden)', type: 'number' },
];
if (statusOpts.length > 0) {
standardTrigger.splice(2, 0, {
name: PAYLOAD_STATUS,
label: 'Status',
type: 'clickup_status',
statusOptions: statusOpts,
});
}
const customInput: FormField[] = [];
const customTrigger: TriggerFormFieldRow[] = [];
const customRefs: Record<string, unknown> = {};
for (const f of listFields) {
if (!f || typeof f !== 'object') continue;
const inf = mapCuToInputFormField(f as ClickUpFieldLike, connectionId, listId);
const tr = mapCuToTriggerFormField(f as ClickUpFieldLike, connectionId, listId);
if (inf) customInput.push(inf);
if (tr) customTrigger.push(tr);
const fid = String((f as ClickUpFieldLike).id ?? '');
const payloadKey = inf?.name;
if (fid && payloadKey) {
customRefs[fid] = createRef(formNodeId, ['payload', payloadKey]);
}
}
const inputFormFields = [...standardInput, ...customInput];
const triggerFormFields = [...standardTrigger, ...customTrigger];
const clickupPatch: Record<string, unknown> = {
connectionId,
teamId,
listId,
path: `/team/${teamId}/list/${listId}`,
name: ref(PAYLOAD_TITLE),
description: ref(PAYLOAD_DESCRIPTION),
taskPriority: ref(PAYLOAD_PRIORITY),
taskDueDateMs: ref(PAYLOAD_DUE),
taskTimeEstimateHours: ref(PAYLOAD_TIME_H),
};
if (statusOpts.length > 0) {
clickupPatch.taskStatus = ref(PAYLOAD_STATUS);
}
if (Object.keys(customRefs).length) {
clickupPatch.customFieldValues = customRefs;
}
return { inputFormFields, triggerFormFields, clickupPatch };
}

View file

@ -8,6 +8,8 @@ export interface DataRef {
type: 'ref';
nodeId: string;
path: (string | number)[];
/** Optional declared type at bind time (for UI / validation hints) */
expectedType?: string;
}
/** Explicit static value wrapper */
@ -63,8 +65,31 @@ export function createSystemVar(variable: string): SystemVarRef {
}
/** Create a reference object */
export function createRef(nodeId: string, path: (string | number)[] = []): DataRef {
return { type: 'ref', nodeId, path };
export function createRef(nodeId: string, path: (string | number)[] = [], expectedType?: string): DataRef {
return { type: 'ref', nodeId, path, ...(expectedType ? { expectedType } : {}) };
}
/**
* Structural type compatibility using the canonical type vocabulary: str / int / float / bool / Any.
* All node parameters and form field schemas must use these types (no `string`, `number`, `boolean`
* aliases) so no alias-mapping is needed here.
*
* `Any` as expected type accepts everything.
* `Any`, `object`, or `dict` as produced type coerces to `str` (backend serializes via json.dumps).
*/
export function isCompatible(producedType: string, expectedType: string): 'ok' | 'coerce' | 'mismatch' {
if (!expectedType || !producedType) return 'ok';
if (producedType === expectedType) return 'ok';
// Any-expected: accept all sources
if (expectedType === 'Any') return 'ok';
// Any-produced: compatible with everything (coerce where needed)
if (producedType === 'Any') return 'coerce';
// Numeric coercion
if (expectedType === 'str' && (producedType === 'int' || producedType === 'float')) return 'coerce';
if (expectedType === 'int' && producedType === 'str') return 'coerce';
// Object/dict → str: backend serializes to JSON text
if (expectedType === 'str' && (producedType === 'object' || producedType === 'dict')) return 'coerce';
return 'mismatch';
}
/** Create a value wrapper */

View file

@ -8,6 +8,7 @@ import type {
Automation2Graph,
Automation2GraphNode,
Automation2Connection,
GraphDefinedSchemaRef,
} from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
@ -42,7 +43,10 @@ export function fromApiGraph(
? Object.entries(nt.inputPorts).map(([, v]) => ({ name: '', schema: '', accepts: (v as { accepts?: string[] }).accepts }))
: undefined,
outputPorts: nt?.outputPorts
? Object.entries(nt.outputPorts).map(([, v]) => ({ name: '', schema: (v as { schema?: string }).schema ?? '' }))
? Object.entries(nt.outputPorts).map(([, v]) => ({
name: '',
schema: (v as { schema?: string | GraphDefinedSchemaRef }).schema ?? '',
}))
: undefined,
};
});

View file

@ -69,9 +69,39 @@ export function buildNodeOutputPreview(
return { _transit: true, _meta: {}, data: {} };
}
if (typeof port0.schema !== 'string') {
return {};
}
return _buildSchemaPreview(port0.schema);
}
function _buildEmailItemPreview(): Record<string, unknown> {
return {
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
subject: '...',
body: { contentType: 'HTML', content: '...' },
receivedDateTime: '2026-01-01T00:00:00Z',
toRecipients: [],
hasAttachments: false,
id: '...',
};
}
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
if (params.resultType !== 'json') return null;
const prompt = String(params.aiPrompt || params.prompt || '');
if (!prompt) return null;
const fields: Record<string, unknown> = {};
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(prompt)) !== null) {
const f = m[1];
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
}
return Object.keys(fields).length > 0 ? fields : null;
}
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
@ -88,5 +118,32 @@ export function buildNodeOutputsPreview(
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
}
}
for (const n of nodes) {
if (n.id in (nodeOutputsFromRun ?? {})) continue;
if (n.type === 'flow.loop') {
const items = n.parameters?.items;
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
const ref = items as { nodeId: string; path?: (string | number)[] };
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
if (sourceSchema === 'EmailList') {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
}
}
}
if (n.type === 'ai.prompt' && n.parameters) {
const rdPreview = _buildAiResponseDataPreview(n.parameters);
if (rdPreview) {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, responseData: rdPreview };
}
}
}
return result;
}

View file

@ -0,0 +1,318 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Plan #2 — Track A1 / FE-Tests
// T5/T6 (RequiredAttributePicker): 0/1/N candidate logic + iterierens-suggestion
// T7 (DataPicker): strict type filtering
// T8 (DataPicker): generic-object drill-down via wildcard segment '*'
//
// We test the pure helpers in paramValidation.ts directly. The component
// pickers are thin shells over these helpers, so covering the helpers covers
// the deterministic core of the binding affordance.
import { describe, expect, it } from 'vitest';
import {
findGraphErrors,
findRequiredErrors,
findSourceCandidates,
isParamBound,
strictlyCompatible,
type SourceCandidate,
} from './paramValidation';
import { createRef, createSystemVar, createValue } from './dataRef';
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType, PortField, PortSchema } from '../../../../api/workflowApi';
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function _field(name: string, type: string): PortField {
return { name, type, description: '', required: false };
}
function _schema(name: string, fields: PortField[]): PortSchema {
return { name, fields };
}
const _docListSchema: PortSchema = _schema('DocumentList', [
_field('documents', 'List[UdmDocument]'),
_field('count', 'int'),
]);
const _udmDocumentSchema: PortSchema = _schema('UdmDocument', [
_field('name', 'str'),
_field('mimeType', 'str'),
_field('sizeBytes', 'int'),
]);
const _aiResultSchema: PortSchema = _schema('AiResult', [
_field('text', 'str'),
_field('tokensUsed', 'int'),
]);
const _portCatalog: Record<string, PortSchema> = {
DocumentList: _docListSchema,
UdmDocument: _udmDocumentSchema,
AiResult: _aiResultSchema,
};
function _makeNode(id: string, type: string, parameters: Record<string, unknown> = {}): CanvasNode {
return {
id,
type,
title: `${id} (${type})`,
x: 0,
y: 0,
inputs: 1,
outputs: 1,
parameters,
};
}
function _makeConnection(id: string, sourceId: string, targetId: string): CanvasConnection {
return {
id,
sourceId,
sourceHandle: 0,
targetId,
targetHandle: 0,
};
}
function _makeNodeType(
id: string,
outputSchema: string,
parameters: NodeType['parameters'] = [],
): NodeType {
return {
id,
label: id,
description: id,
category: 'test',
parameters,
inputs: 1,
outputs: 1,
outputPorts: [{ schema: outputSchema }],
} as unknown as NodeType;
}
// ---------------------------------------------------------------------------
// isParamBound
// ---------------------------------------------------------------------------
describe('isParamBound', () => {
it('returns false for null/undefined/empty string', () => {
expect(isParamBound(null)).toBe(false);
expect(isParamBound(undefined)).toBe(false);
expect(isParamBound('')).toBe(false);
});
it('returns true for non-empty string/number/boolean', () => {
expect(isParamBound('hello')).toBe(true);
expect(isParamBound(0)).toBe(true);
expect(isParamBound(false)).toBe(true);
});
it('returns true for a valid DataRef and false for one without nodeId', () => {
expect(isParamBound(createRef('node-1', ['x']))).toBe(true);
expect(isParamBound({ type: 'ref', nodeId: '', path: [] })).toBe(false);
});
it('returns true for a SystemVarRef with a variable name', () => {
expect(isParamBound(createSystemVar('user.id'))).toBe(true);
expect(isParamBound({ type: 'system', variable: '' })).toBe(false);
});
it('treats {type:"value", value:""} as unbound but {value:0} as bound', () => {
expect(isParamBound(createValue(''))).toBe(false);
expect(isParamBound(createValue(0))).toBe(true);
expect(isParamBound(createValue([]))).toBe(false);
expect(isParamBound(createValue(['a']))).toBe(true);
});
it('counts non-empty arrays/objects as bound', () => {
expect(isParamBound([])).toBe(false);
expect(isParamBound([1])).toBe(true);
expect(isParamBound({})).toBe(false);
expect(isParamBound({ k: 1 })).toBe(true);
});
});
// ---------------------------------------------------------------------------
// findRequiredErrors / findGraphErrors
// ---------------------------------------------------------------------------
describe('findRequiredErrors', () => {
it('returns empty when all required params are bound', () => {
const node = _makeNode('n1', 'ai.process', {
aiPrompt: 'hello',
documentList: createRef('upstream', ['documents']),
});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
expect(findRequiredErrors(node, nodeType)).toEqual([]);
});
it('flags every unbound required param with its name + type', () => {
const node = _makeNode('n1', 'ai.process', {});
const nodeType = _makeNodeType('ai.process', 'AiResult', [
{ name: 'aiPrompt', type: 'str', required: true },
{ name: 'documentList', type: 'DocumentList', required: true },
{ name: 'optional', type: 'str', required: false },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(2);
expect(errs.map((e) => e.paramName)).toEqual(['aiPrompt', 'documentList']);
});
it('returns empty list when nodeType is unknown', () => {
const node = _makeNode('n1', 'ghost.node');
expect(findRequiredErrors(node, undefined)).toEqual([]);
});
it('skips required params with frontendType="hidden" (UI safety net)', () => {
// Hidden params have no UI surface, so reporting them as
// "Pflichtfeld ohne Quelle" would create a phantom error the user can
// not resolve. They are auto-set by adapters / system defaults.
const node = _makeNode('n1', 'trustee.extractFromFiles', {});
const nodeType = _makeNodeType('trustee.extractFromFiles', 'AiResult', [
{ name: 'prompt', type: 'str', required: true },
{ name: 'systemContext', type: 'str', required: true, frontendType: 'hidden' },
]);
const errs = findRequiredErrors(node, nodeType);
expect(errs).toHaveLength(1);
expect(errs[0]!.paramName).toBe('prompt');
});
});
describe('findGraphErrors', () => {
it('aggregates per-node errors and omits clean nodes', () => {
const cleanNodeType = _makeNodeType('clean.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
]);
const dirtyNodeType = _makeNodeType('dirty.node', 'AiResult', [
{ name: 'p1', type: 'str', required: true },
{ name: 'p2', type: 'str', required: true },
]);
const nodes: CanvasNode[] = [
_makeNode('clean', 'clean.node', { p1: 'value' }),
_makeNode('dirty', 'dirty.node', { p1: 'set' }),
];
const result = findGraphErrors(nodes, [cleanNodeType, dirtyNodeType]);
expect(Object.keys(result)).toEqual(['dirty']);
expect(result['dirty']!.map((e) => e.paramName)).toEqual(['p2']);
});
});
// ---------------------------------------------------------------------------
// findSourceCandidates — T5/T6/T7/T8 core
// ---------------------------------------------------------------------------
describe('findSourceCandidates', () => {
function _makeFixture() {
const upstreamType = _makeNodeType('sharepoint.readDocs', 'DocumentList');
const consumerType = _makeNodeType('ai.summarize', 'AiResult', [
{ name: 'documentList', type: 'DocumentList', required: true },
]);
const upstream = _makeNode('up', 'sharepoint.readDocs');
const consumer = _makeNode('cons', 'ai.summarize');
const conns = [_makeConnection('c1', 'up', 'cons')];
return { nodes: [upstream, consumer], connections: conns, nodeTypes: [upstreamType, consumerType] };
}
it('returns the whole-output candidate first (path=[]) for the upstream', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'DocumentList',
...f,
portTypeCatalog: _portCatalog,
});
const wholeOutput = candidates.find((c) => c.nodeId === 'up' && c.path.length === 0);
expect(wholeOutput).toBeDefined();
expect(wholeOutput!.type).toBe('DocumentList');
expect(wholeOutput!.compat).toBe('ok');
});
it('drills into List[X] elements via wildcard "*" segment (T8 generic drill-down)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'str',
...f,
portTypeCatalog: _portCatalog,
});
const wildcardCandidate = candidates.find(
(c) =>
c.nodeId === 'up' &&
c.path[0] === 'documents' &&
c.path[1] === '*' &&
c.path[2] === 'name',
);
expect(wildcardCandidate).toBeDefined();
expect(wildcardCandidate!.type).toBe('str');
expect(wildcardCandidate!.compat).toBe('ok');
});
it('marks List[X] → X as iterable (T6 "iterieren"-Vorschlag)', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
expectedType: 'UdmDocument',
...f,
portTypeCatalog: _portCatalog,
});
const iterable = candidates.find(
(c) => c.nodeId === 'up' && c.path.length === 1 && c.path[0] === 'documents' && c.iterable,
);
expect(iterable).toBeDefined();
expect(iterable!.type).toBe('List[UdmDocument]');
});
it('returns no candidates when no upstream is connected (T5: 0-case)', () => {
const f = _makeFixture();
const isolated = _makeNode('iso', 'ai.summarize');
const candidates = findSourceCandidates({
consumerNodeId: 'iso',
expectedType: 'DocumentList',
nodes: [...f.nodes, isolated],
connections: f.connections,
nodeTypes: f.nodeTypes,
portTypeCatalog: _portCatalog,
});
expect(candidates).toEqual([]);
});
it('returns plain candidates (compat="ok") when expectedType is omitted', () => {
const f = _makeFixture();
const candidates = findSourceCandidates({
consumerNodeId: 'cons',
...f,
portTypeCatalog: _portCatalog,
});
expect(candidates.length).toBeGreaterThan(0);
expect(candidates.every((c) => c.compat === 'ok')).toBe(true);
});
});
// ---------------------------------------------------------------------------
// strictlyCompatible — T7 strict type filter
// ---------------------------------------------------------------------------
describe('strictlyCompatible', () => {
it('keeps only ok / coerce / iterable candidates and drops mismatch', () => {
const all: SourceCandidate[] = [
{ nodeId: 'a', path: [], type: 'DocumentList', compat: 'ok' },
{ nodeId: 'a', path: ['documents'], type: 'List[UdmDocument]', compat: 'mismatch', iterable: true },
{ nodeId: 'a', path: ['count'], type: 'int', compat: 'coerce' },
{ nodeId: 'a', path: ['junk'], type: 'object', compat: 'mismatch' },
];
const out = strictlyCompatible(all);
expect(out.map((c) => c.path)).toEqual([[], ['documents'], ['count']]);
});
});

View file

@ -0,0 +1,216 @@
/**
* Phase-4 Schicht-4 (Instanz-Bindings) Validation utilities.
*
* Single source of truth for two questions every UI surface needs to answer:
* 1. "Is this required parameter on this node bound to anything?"
* 2. "Which upstream nodes are type-compatible sources for this parameter?"
*
* Used by:
* - RequiredAttributePicker (renders 0/1/N affordance based on candidate count)
* - NodeConfigPanel (orders required params first, surfaces missing-source pill)
* - FlowCanvas (red error badge per node when any required param is unbound)
* - CanvasHeader (Run button disabled when any node has unbound required params)
*
* The required check is deliberately conservative: a param counts as "bound"
* if it has any non-empty value, a non-empty static value-wrapper, a ref, or a
* system-var ref. Empty string / null / undefined / { type: 'value', value: '' }
* all count as unbound.
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { GraphDefinedSchemaRef, NodeType, NodeTypeParameter, OutputPortDef, PortSchema } from '../../../../api/workflowApi';
import { isCompatible, isRef, isSystemVar, isValue } from './dataRef';
import { getAvailableSources } from './dataFlowGraph';
const _LIST_INNER_RE = /^List\[(.+)\]$/;
/** A candidate path on an upstream node that could satisfy a parameter binding. */
export interface SourceCandidate {
nodeId: string;
/** JSON path on the node output, e.g. ['documents', 0, 'name']. */
path: (string | number)[];
/** Type as declared by the schema field at this path (best-effort). */
type?: string;
/** Compatibility verdict against the requested type. */
compat: 'ok' | 'coerce' | 'mismatch';
/** True iff the candidate is a List that, by element-iteration ('*'), would
* satisfy the requested scalar type the "iterieren als Loop-Vorschlag". */
iterable?: boolean;
}
/** Decide whether a parameter value counts as "bound" for required-check purposes. */
export function isParamBound(value: unknown): boolean {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.length > 0;
if (typeof value === 'number' || typeof value === 'boolean') return true;
if (isRef(value)) return Boolean(value.nodeId);
if (isSystemVar(value)) return Boolean(value.variable);
if (isValue(value)) {
const inner = value.value;
if (inner === null || inner === undefined) return false;
if (typeof inner === 'string') return inner.length > 0;
if (Array.isArray(inner)) return inner.length > 0;
return true;
}
if (Array.isArray(value)) return value.length > 0;
if (typeof value === 'object') return Object.keys(value as object).length > 0;
return false;
}
/** A "required" param on a node that has no value and no incoming binding. */
export interface RequiredParamError {
paramName: string;
paramLabel: string;
paramType?: string;
}
/** Walk a node's parameter spec + values and flag every required-but-unbound.
*
* Safety net: params with `frontendType: 'hidden'` are excluded they have
* no UI surface (the panel skips them entirely), so reporting them as
* "Pflichtfeld ohne Quelle" would create a phantom error the user cannot
* resolve. Hidden-required params should be auto-set by the adapter or
* caught in tests, never surfaced to end users.
*/
export function findRequiredErrors(
node: CanvasNode,
nodeType: NodeType | undefined,
resolveLabel: (param: NodeTypeParameter) => string = (p) => p.name,
): RequiredParamError[] {
if (!nodeType) return [];
const errors: RequiredParamError[] = [];
const values = node.parameters ?? {};
for (const param of nodeType.parameters ?? []) {
if (!param.required) continue;
if (param.frontendType === 'hidden') continue;
if (isParamBound(values[param.name])) continue;
errors.push({ paramName: param.name, paramLabel: resolveLabel(param), paramType: param.type });
}
return errors;
}
/** Map of nodeId → required errors. Empty entries are omitted. */
export function findGraphErrors(
nodes: CanvasNode[],
nodeTypes: NodeType[],
resolveLabel?: (param: NodeTypeParameter) => string,
): Record<string, RequiredParamError[]> {
const byId: Record<string, RequiredParamError[]> = {};
const byTypeId = new Map(nodeTypes.map((nt) => [nt.id, nt]));
for (const n of nodes) {
const errs = findRequiredErrors(n, byTypeId.get(n.type), resolveLabel);
if (errs.length) byId[n.id] = errs;
}
return byId;
}
/** Resolve the schema produced by an output port (Transit follows incoming connection). */
function _resolveOutputSchemaName(
nodeId: string,
nodes: CanvasNode[],
connections: CanvasConnection[],
nodeTypes: NodeType[],
visited: Set<string> = new Set(),
): { schemaName?: string; node?: CanvasNode; portDef?: OutputPortDef } {
if (visited.has(nodeId)) return {};
visited.add(nodeId);
const node = nodes.find((n) => n.id === nodeId);
if (!node) return {};
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
const port0 = typeDef?.outputPorts?.[0] as OutputPortDef | undefined;
if (!port0) return { node };
const spec = port0.schema as string | GraphDefinedSchemaRef | undefined;
if (typeof spec === 'object' && spec !== null && spec.kind === 'fromGraph') {
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
}
if (port0.dynamic) {
return { schemaName: 'FormPayload_dynamic', node, portDef: port0 };
}
if (typeof spec === 'string' && spec !== 'Transit') {
return { schemaName: spec, node, portDef: port0 };
}
// Transit: follow upstream
const incoming = connections.find((c) => c.targetId === nodeId);
if (!incoming) return { node };
return _resolveOutputSchemaName(incoming.sourceId, nodes, connections, nodeTypes, visited);
}
/** Build candidate paths from a schema, recursing into List-element schemas one level deep. */
function _candidatesFromSchema(
schema: PortSchema | undefined,
catalog: Record<string, PortSchema>,
basePath: (string | number)[] = [],
depth = 0,
): Array<{ path: (string | number)[]; type?: string }> {
if (!schema || !schema.fields || depth > 6) return [];
const out: Array<{ path: (string | number)[]; type?: string }> = [];
for (const field of schema.fields) {
const fieldPath = [...basePath, field.name];
out.push({ path: fieldPath, type: field.type });
const inner = typeof field.type === 'string' ? field.type.match(_LIST_INNER_RE)?.[1]?.trim() : undefined;
if (inner && catalog[inner]) {
out.push(..._candidatesFromSchema(catalog[inner], catalog, [...fieldPath, '*'], depth + 1));
}
}
return out;
}
/**
* Compute every typed source candidate that could satisfy `expectedType`
* for the given consumer node. Includes ranked compatibility per candidate
* and a `iterable` flag for List-XX "iterate as Loop" suggestions.
*
* If `expectedType` is omitted, returns all candidates (all marked 'ok').
*/
export function findSourceCandidates(args: {
consumerNodeId: string;
expectedType?: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
nodeTypes: NodeType[];
portTypeCatalog: Record<string, PortSchema>;
}): SourceCandidate[] {
const { consumerNodeId, expectedType, nodes, connections, nodeTypes, portTypeCatalog } = args;
const sourceIds = getAvailableSources(consumerNodeId, nodes, connections).filter((id) => {
const n = nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const results: SourceCandidate[] = [];
for (const nid of sourceIds) {
const { schemaName } = _resolveOutputSchemaName(nid, nodes, connections, nodeTypes);
const schema = schemaName ? portTypeCatalog[schemaName] : undefined;
const wholeType = schemaName ?? undefined;
results.push({
nodeId: nid,
path: [],
type: wholeType,
compat: expectedType && wholeType ? isCompatible(wholeType, expectedType) : 'ok',
iterable: _isIterableMatch(wholeType, expectedType),
});
for (const cand of _candidatesFromSchema(schema, portTypeCatalog)) {
const compat = expectedType && cand.type ? isCompatible(cand.type, expectedType) : 'ok';
results.push({
nodeId: nid,
path: cand.path,
type: cand.type,
compat,
iterable: _isIterableMatch(cand.type, expectedType),
});
}
}
return results;
}
/** True iff `producedType` is `List[X]` and `expectedType` equals `X`. */
function _isIterableMatch(producedType?: string, expectedType?: string): boolean {
if (!producedType || !expectedType) return false;
const m = producedType.match(_LIST_INNER_RE);
if (!m) return false;
return m[1].trim() === expectedType;
}
/** Filter candidates to only those that satisfy `expectedType` (strict mode). */
export function strictlyCompatible(candidates: SourceCandidate[]): SourceCandidate[] {
return candidates.filter((c) => c.compat === 'ok' || c.compat === 'coerce' || c.iterable === true);
}

View file

@ -0,0 +1,55 @@
/**
* Lexical scope for DataPicker: ancestor node ids reachable backward on the graph.
*/
export interface GraphEdgeLike {
source: string;
target: string;
}
export interface GraphNodeLike {
id: string;
type?: string;
}
/** All node ids that can reach targetNodeId via incoming edges (excluding target). */
export function computeAncestorNodeIds(
_nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): Set<string> {
const preds = new Map<string, Set<string>>();
for (const c of connections) {
const src = c.source;
const tgt = c.target;
if (!src || !tgt) continue;
if (!preds.has(tgt)) preds.set(tgt, new Set());
preds.get(tgt)!.add(src);
}
const seen = new Set<string>();
const stack = [targetNodeId];
while (stack.length) {
const cur = stack.pop()!;
const ps = preds.get(cur);
if (!ps) continue;
for (const p of ps) {
if (!seen.has(p)) {
seen.add(p);
stack.push(p);
}
}
}
seen.delete(targetNodeId);
return seen;
}
/** Node ids of flow.loop ancestors (subset of ancestors). */
export function findLoopAncestorIds(
nodes: GraphNodeLike[],
connections: GraphEdgeLike[],
targetNodeId: string
): string[] {
const anc = computeAncestorNodeIds(nodes, connections, targetNodeId);
const byId = new Map(nodes.map((n) => [n.id, n]));
return [...anc].filter((id) => byId.get(id)?.type === 'flow.loop');
}

View file

@ -3,17 +3,15 @@
*/
import type { ApiRequestFunction } from '../../../../api/workflowApi';
import type { AttributeType } from '../../../../utils/attributeTypeMapper';
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
/** input.form / trigger.form field row. */
export type FormField = {
name?: string;
type?: string;
type?: AttributeType;
label?: string;
required?: boolean;
clickupConnectionId?: string;
clickupListId?: string;
/** ClickUp list status names from GET /list/{id} — only for type `clickup_status`. */
clickupStatusOptions?: Array<{ value: string; label: string }>;
options?: Array<{ value: string; label: string }>;
};
export interface NodeConfigRendererProps {

View file

@ -24,11 +24,110 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
export function getAcceptStringFromConfig(
config: Record<string, unknown>
): string {
const types = config.allowedTypes;
if (!Array.isArray(types) || types.length === 0) return '*';
return types.join(',');
/** Extension → MIME when the browser leaves ``File.type`` empty (common on Windows). */
const _EXT_TO_MIME: Record<string, string> = {
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
'.json': 'application/json',
'.xml': 'application/xml',
'.zip': 'application/zip',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.jpe': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
};
function _extensionVariants(ext: string): string[] {
const e = ext.toLowerCase();
if (e === '.jpeg' || e === '.jpe') return ['.jpeg', '.jpe', '.jpg'];
if (e === '.jpg') return ['.jpg', '.jpeg', '.jpe'];
return [e];
}
/**
* True if ``file`` satisfies an HTML-style ``accept`` string (extensions, MIME types, ``image/*``).
* - ``*`` or empty allow all
* - Normalizes gateway multiselect tokens ``pdf`` ``.pdf`` (via {@link getAcceptStringFromConfig})
* - Infers MIME from extension when ``file.type`` is empty
*/
export function fileMatchesAccept(file: File, accept: string): boolean {
const trimmed = (accept ?? '').trim();
if (!trimmed || trimmed === '*') return true;
const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return true;
const name = file.name ?? '';
const ext =
name.includes('.') && !name.endsWith('.')
? '.' + (name.split('.').pop() ?? '').toLowerCase()
: '';
let mime = (file.type ?? '').trim().toLowerCase();
if (!mime && ext && _EXT_TO_MIME[ext]) {
mime = _EXT_TO_MIME[ext];
}
const extVariants = ext ? _extensionVariants(ext) : [];
for (const rawPart of parts) {
for (const p of rawPart.split(',').map((s) => s.trim()).filter(Boolean)) {
const pp = p.toLowerCase();
if (pp === '*') return true;
if (pp.startsWith('.')) {
if (extVariants.some((e) => e === pp)) return true;
continue;
}
if (pp.endsWith('/*')) {
const prefix = pp.slice(0, -2);
if (mime.startsWith(prefix + '/')) return true;
continue;
}
if (pp.includes('/')) {
if (mime === pp) return true;
continue;
}
// Bare token left from legacy configs, e.g. "pdf" without dot
if (/^[a-z0-9]{2,16}$/.test(pp)) {
const dotted = '.' + pp;
if (extVariants.includes(dotted)) return true;
if (extVariants.some((e) => _extensionVariants(e).includes(dotted))) return true;
}
}
}
return false;
}
/**
* Build a combined accept list from ``allowedTypes`` (multiselect: pdf, docx, ) and optional
* manual ``accept`` string on the node.
*/
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
const fromParam =
typeof config.accept === 'string' && config.accept.trim() ? config.accept.trim() : '';
const types = config.allowedTypes;
let fromAllowed = '';
if (Array.isArray(types) && types.length > 0) {
fromAllowed = types
.map((t) => {
const s = String(t).trim().toLowerCase();
if (!s) return '';
if (s === '*') return '*';
if (s.includes('/') || s.endsWith('/*')) return s;
if (s.startsWith('.')) return s;
return `.${s.replace(/^\.+/, '')}`;
})
.filter(Boolean)
.join(',');
}
if (fromParam && fromAllowed) return `${fromParam},${fromAllowed}`;
if (fromParam) return fromParam;
if (fromAllowed) return fromAllowed;
return '*';
}

View file

@ -4,40 +4,24 @@
import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../shared/types';
import type { FormField } from '../shared/types';
import { FORM_FIELD_TYPES, FORM_FIELD_TYPE_LABELS } from '../../../../utils/attributeTypeMapper';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
type FormField = {
name: string;
label: string;
type: 'text' | 'number' | 'email' | 'date' | 'boolean' | 'clickup_status';
statusOptions?: Array<{ value: string; label: string }>;
};
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
const fieldType = String(o.type ?? 'text');
const rawType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = (
FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return {
name,
label,
type: 'clickup_status',
statusOptions: o.statusOptions as Array<{ value: string; label: string }>,
};
}
return { name, label, type };
const type = (FORM_FIELD_TYPES as readonly string[]).includes(rawType) ? rawType : 'text';
return { name, label, type } as FormField;
}
return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
});
@ -45,6 +29,10 @@ function _parseFields(params: Record<string, unknown>, t: (key: string) => strin
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const ctx = useAutomation2DataFlow();
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 = useMemo(() => _parseFields(params, t), [params, t]);
const setFields = (next: FormField[]) => {
@ -64,7 +52,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Name (Payload-Key)')}
value={f.name}
value={f.name ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, name: e.target.value };
@ -74,7 +62,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
<input
className={styles.startsInput}
placeholder={t('Beschriftung')}
value={f.label}
value={f.label ?? ''}
onChange={(e) => {
const next = [...fields];
next[idx] = { ...f, label: e.target.value };
@ -83,24 +71,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/>
<select
className={styles.startsSelect}
value={f.type}
value={f.type ?? 'text'}
onChange={(e) => {
const next = [...fields];
const fieldType = e.target.value as FormField['type'];
if (fieldType === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else {
next[idx] = { name: f.name, label: f.label, type: fieldType };
}
next[idx] = { name: f.name, label: f.label, type: e.target.value as FormField['type'] };
setFields(next);
}}
>
<option value="text">{t('Text')}</option>
<option value="number">{t('Zahl')}</option>
<option value="email">{t('E-Mail')}</option>
<option value="date">{t('Datum')}</option>
<option value="boolean">{t('Ja/Nein')}</option>
<option value="clickup_status">{t('ClickUp-Status Liste')}</option>
{fieldTypeOptions.map((ft) => (
<option key={ft.id} value={ft.id}>{t(ft.label)}</option>
))}
</select>
<button
type="button"

View file

@ -1,187 +0,0 @@
.folderTree {
font-size: 0.875rem;
user-select: none;
}
.treeNode {
display: flex;
align-items: center;
padding: 2px 4px;
cursor: pointer;
border-radius: 4px;
gap: 2px;
min-height: 26px;
position: relative;
}
.treeNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.treeNode.selected {
background: var(--color-bg-selected, rgba(25, 118, 210, 0.08));
font-weight: 600;
}
.treeNode.multiSelected {
background: var(--color-bg-multi-selected, rgba(25, 118, 210, 0.14));
box-shadow: inset 3px 0 0 var(--color-primary, #F25843);
}
.treeNode.multiSelected:hover {
background: var(--color-bg-multi-selected-hover, rgba(25, 118, 210, 0.20));
}
.treeNode.dropTarget {
background: var(--color-bg-drop, rgba(25, 118, 210, 0.15));
outline: 2px dashed var(--color-primary, #F25843);
outline-offset: -2px;
}
.treeNode.dragging {
opacity: 0.5;
}
.chevron {
width: 12px;
height: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s ease;
color: var(--color-text-secondary, #666);
font-size: 8px;
}
.chevron.expanded {
transform: rotate(90deg);
}
.chevron.empty {
visibility: hidden;
}
.folderIcon {
flex-shrink: 0;
color: var(--color-text-secondary, #888);
font-size: 13px;
}
.folderName {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.renameInput {
flex: 1;
border: 1px solid var(--color-primary, #F25843);
border-radius: 3px;
padding: 1px 4px;
font-size: inherit;
font-family: inherit;
outline: none;
min-width: 0;
}
/* Right zone: contains dynamic on-hover actions + always-visible stable trio.
* The stable trio (chat / scope / neutralize) sits at the right edge in a
* fixed slot order so icons never jump. Dynamic actions appear on hover
* to the left of the trio without displacing it. */
.rightZone {
display: flex;
align-items: center;
gap: 4px;
margin-left: auto;
flex-shrink: 0;
}
.actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.treeNode:hover .actions {
display: flex;
}
.stableActions {
display: flex;
gap: 2px;
flex-shrink: 0;
align-items: center;
}
.iconSlot {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 20px;
flex-shrink: 0;
}
.iconSlot.placeholder {
visibility: hidden;
}
.actionBtn {
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
color: var(--color-text-secondary, #888);
font-size: 12px;
line-height: 1;
display: flex;
align-items: center;
}
.actionBtn:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.08));
color: var(--color-text-primary, #333);
}
.actionBtn.danger:hover {
color: var(--color-error, #d32f2f);
}
.children {
padding-left: 10px;
}
.rootLabel {
font-weight: 600;
color: var(--color-text-primary, #333);
}
/* File nodes inside the tree */
.fileNode {
cursor: pointer;
}
.fileNode:hover {
background: var(--color-bg-hover, rgba(0, 0, 0, 0.04));
}
.fileIcon {
flex-shrink: 0;
font-size: 11px;
}
.fileSize {
font-size: 10px;
color: var(--color-text-secondary, #999);
flex-shrink: 0;
}
.rootActions {
display: flex;
gap: 2px;
margin-left: auto;
flex-shrink: 0;
}

View file

@ -1,915 +0,0 @@
/**
* FolderTree Shared recursive folder/file tree component.
*
* Used on the Files page and in the Workspace chat.
* Supports:
* - Alphabetical sorting per level (folders first, then files)
* - Multi-selection (CTRL+click, SHIFT+click) with visual highlight
* - Batch drag-and-drop for selected items
* - Inline CRUD icons for folders
* - showFiles mode renders files inline under their parent folder
* - Drag-out: sets application/tree-items on dataTransfer for external drop targets
*/
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaSyncAlt, FaDownload } from 'react-icons/fa';
import { usePrompt, type PromptOptions } from '../../hooks/usePrompt';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
/* ── Public types ──────────────────────────────────────────────────────── */
export interface FolderNode {
id: string;
name: string;
parentId: string | null;
fileCount?: number;
children?: FolderNode[];
isProtected?: boolean;
isReadonly?: boolean;
icon?: string;
neutralize?: boolean;
scope?: string;
}
export interface FileNode {
id: string;
fileName: string;
mimeType?: string;
fileSize?: number;
folderId?: string | null;
scope?: string;
neutralize?: boolean;
sysCreatedBy?: string;
isReadonly?: boolean;
}
export interface TreeItem {
id: string;
type: 'file' | 'folder';
name: string;
}
export interface FolderTreeProps {
folders: FolderNode[];
files?: FileNode[];
showFiles?: boolean;
selectedFolderId: string | null;
onSelect: (folderId: string | null) => void;
onFileSelect?: (fileId: string) => void;
selectedItemIds?: Set<string>;
onSelectionChange?: (selectedIds: Set<string>) => void;
expandedIds?: Set<string>;
onToggleExpand?: (id: string) => void;
onRefresh?: () => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* ── Helpers ───────────────────────────────────────────────────────────── */
function _buildTree(folders: FolderNode[]): FolderNode[] {
const map = new Map<string, FolderNode>();
const roots: FolderNode[] = [];
for (const f of folders) map.set(f.id, { ...f, children: [] });
for (const f of folders) {
const node = map.get(f.id)!;
if (f.parentId && map.has(f.parentId)) {
map.get(f.parentId)!.children!.push(node);
} else {
roots.push(node);
}
}
const _sortLevel = (nodes: FolderNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
for (const n of nodes) {
if (n.children && n.children.length > 0) _sortLevel(n.children);
}
};
_sortLevel(roots);
return roots;
}
function _groupFilesByFolder(files: FileNode[]): Map<string, FileNode[]> {
const map = new Map<string, FileNode[]>();
for (const f of files) {
const key = f.folderId || '';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(f);
}
for (const [, arr] of map) {
arr.sort((a, b) => a.fileName.localeCompare(b.fileName, undefined, { sensitivity: 'base' }));
}
return map;
}
function _computeFlatList(
tree: FolderNode[],
expandedIds: Set<string>,
showFiles: boolean,
filesByFolder: Map<string, FileNode[]>,
): TreeItem[] {
const result: TreeItem[] = [];
const _walk = (nodes: FolderNode[]) => {
for (const node of nodes) {
result.push({ id: node.id, type: 'folder', name: node.name });
if (expandedIds.has(node.id)) {
if (node.children) _walk(node.children);
if (showFiles) {
for (const f of (filesByFolder.get(node.id) || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
}
}
};
_walk(tree);
if (showFiles) {
for (const f of (filesByFolder.get('') || [])) {
result.push({ id: f.id, type: 'file', name: f.fileName });
}
}
return result;
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
if (mime.startsWith('audio/')) return '\uD83C\uDFB5';
if (mime.startsWith('video/')) return '\uD83C\uDFA5';
return '\uD83D\uDCC4';
}
/* ── Selection context threaded through the tree ──────────────────────── */
const _SCOPE_ICONS: Record<string, string> = {
personal: '\uD83D\uDC64',
featureInstance: '\uD83D\uDC65',
mandate: '\uD83C\uDFE2',
global: '\uD83C\uDF10',
};
const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate'];
interface SelectionCtx {
selectedItemIds: Set<string>;
selectedFileIds: string[];
selectedFolderIds: string[];
onItemClick: (id: string, type: 'file' | 'folder', e: React.MouseEvent) => void;
onItemDragStart: (e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => void;
onRenameFile?: (fileId: string, newName: string) => Promise<void>;
onDeleteFile?: (fileId: string) => Promise<void>;
onDeleteFiles?: (fileIds: string[]) => Promise<void>;
onDeleteFolders?: (folderIds: string[]) => Promise<void>;
onScopeChange?: (fileId: string, newScope: string) => void;
onNeutralizeToggle?: (fileId: string, newValue: boolean) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
}
/* Stable trio (chat | scope | neutralize)
* Always rendered in this order, always at the right edge of the row.
* Each slot has a fixed width so missing actions render an invisible
* placeholder icons never jump position between rows. */
interface StableTrioProps {
scope?: string;
neutralize?: boolean;
scopeLabels: Record<string, string>;
onChat?: () => void;
onScopeChange?: (newScope: string) => void;
onNeutralizeToggle?: (newValue: boolean) => void;
chatTitle: string;
}
function _StableTrio({
scope, neutralize,
scopeLabels,
onChat, onScopeChange, onNeutralizeToggle,
chatTitle,
}: StableTrioProps) {
const { t } = useLanguage();
const _cycleScope = (current: string | undefined) => {
const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal');
return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length];
};
return (
<span className={styles.stableActions}>
{/* Slot 1: Chat */}
{onChat ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onChat(); }}
title={chatTitle}
style={{ fontSize: 12 }}
>
{'\u{1F4AC}'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\u{1F4AC}'}</span>
)}
{/* Slot 2: Scope */}
{onScopeChange && scope != null ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onScopeChange(_cycleScope(scope)); }}
title={`${t('Scope')}: ${scopeLabels[scope] || scope} (${t('klicken zum Wechseln')})`}
style={{ fontSize: 14 }}
>
{_SCOPE_ICONS[scope] || _SCOPE_ICONS.personal}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{_SCOPE_ICONS.personal}</span>
)}
{/* Slot 3: Neutralize */}
{onNeutralizeToggle ? (
<button
className={`${styles.actionBtn} ${styles.iconSlot}`}
onClick={(e) => { e.stopPropagation(); onNeutralizeToggle(!neutralize); }}
title={neutralize ? t('Neutralisierung aktiv, klicken zum Deaktivieren') : t('Neutralisierung aus, klicken zum Aktivieren')}
style={{ fontSize: 14, opacity: neutralize ? 1 : 0.4 }}
>
{'\uD83D\uDD12'}
</button>
) : (
<span className={`${styles.iconSlot} ${styles.placeholder}`} aria-hidden="true">{'\uD83D\uDD12'}</span>
)}
</span>
);
}
/* ── File node (leaf) ─────────────────────────────────────────────────── */
function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [dragging, setDragging] = useState(false);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState('');
const isSelected = sel.selectedItemIds.has(file.id);
const multiSelected = sel.selectedItemIds.size > 1;
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== file.fileName && sel.onRenameFile) {
await sel.onRenameFile(file.id, trimmed);
}
setRenaming(false);
}, [renameValue, file.id, file.fileName, sel.onRenameFile]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.onDeleteFile) await sel.onDeleteFile(file.id);
}, [file.id, sel]);
return (
<div
className={[
styles.treeNode,
styles.fileNode,
isSelected ? styles.multiSelected : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ')}
onClick={(e) => sel.onItemClick(file.id, 'file', e)}
draggable
onDragStart={(e) => {
sel.onItemDragStart(e, file.id, 'file', file.fileName);
setDragging(true);
}}
onDragEnd={() => setDragging(false)}
>
<span className={styles.chevron} style={{ visibility: 'hidden' }}><FaChevronRight /></span>
<span className={styles.fileIcon}>{_fileIcon(file.mimeType)}</span>
{renaming ? (
<input
autoFocus
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName}>{file.fileName}</span>
)}
{!renaming && (
<span className={styles.rightZone}>
{file.fileSize != null && (
<span className={styles.fileSize}>
{(file.fileSize / 1024).toFixed(0)}K
</span>
)}
<span className={styles.actions}>
{sel.onRenameFile && !multiSelected && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(file.fileName); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{multiSelected && isSelected ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={t('{count} Ordner löschen', { count: String(sel.selectedFolderIds.length) })}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : (
(sel.onDeleteFile || sel.onDeleteFiles) && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)
)}
</span>
<_StableTrio
scope={file.scope}
neutralize={file.neutralize}
scopeLabels={scopeLabels}
onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined}
onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined}
onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</span>
)}
</div>
);
}
/* ── Tree node (folder) ───────────────────────────────────────────────── */
interface TreeNodeProps {
node: FolderNode;
depth: number;
selectedFolderId: string | null;
expandedIds: Set<string>;
showFiles: boolean;
filesByFolder: Map<string, FileNode[]>;
sel: SelectionCtx;
promptFolderName: (message: string, options?: PromptOptions) => Promise<string | null>;
onToggle: (id: string) => void;
onSelect: (id: string | null) => void;
onCreateFolder?: (name: string, parentId: string | null) => Promise<void>;
onRenameFolder?: (folderId: string, newName: string) => Promise<void>;
onDeleteFolder?: (folderId: string) => Promise<void>;
onMoveFolder?: (folderId: string, targetParentId: string | null) => Promise<void>;
onMoveFolders?: (folderIds: string[], targetParentId: string | null) => Promise<void>;
onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise<void>;
onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise<void>;
onDownloadFolder?: (folderId: string, folderName: string) => Promise<void>;
onFolderScopeChange?: (folderId: string, newScope: string) => void;
onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void;
}
function _TreeNode({
node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel,
promptFolderName,
onToggle, onSelect,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle,
}: TreeNodeProps) {
const { t } = useLanguage();
const scopeLabels = useMemo((): Record<string, string> => ({
personal: t('Persönlich'),
featureInstance: t('Instanz'),
mandate: t('Mandant'),
global: t('Global'),
}), [t]);
const [renaming, setRenaming] = useState(false);
const [renameValue, setRenameValue] = useState(node.name);
const [dropOver, setDropOver] = useState(false);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const isExpanded = expandedIds.has(node.id);
const isNavSelected = selectedFolderId === node.id;
const isMultiSelected = sel.selectedItemIds.has(node.id);
const folderFiles = showFiles ? (filesByFolder.get(node.id) || []) : [];
const hasChildren = (node.children && node.children.length > 0) || folderFiles.length > 0 || (node.fileCount ?? 0) > 0;
useEffect(() => {
if (renaming && inputRef.current) inputRef.current.focus();
}, [renaming]);
const _handleRename = useCallback(async () => {
const trimmed = renameValue.trim();
if (trimmed && trimmed !== node.name && onRenameFolder) {
await onRenameFolder(node.id, trimmed);
}
setRenaming(false);
}, [renameValue, node.id, node.name, onRenameFolder]);
const _handleAdd = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (!onCreateFolder) return;
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) {
await onCreateFolder(name.trim(), node.id);
if (!expandedIds.has(node.id)) onToggle(node.id);
}
}, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName, t]);
const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteFolder) await onDeleteFolder(node.id);
}, [onDeleteFolder, node.id]);
const _handleDeleteFolders = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFolderIds.length > 0 && sel.onDeleteFolders) {
await sel.onDeleteFolders(sel.selectedFolderIds);
}
}, [sel]);
const _handleDeleteFiles = useCallback(async (e: React.MouseEvent) => {
e.stopPropagation();
if (sel.selectedFileIds.length > 0 && sel.onDeleteFiles) {
await sel.onDeleteFiles(sel.selectedFileIds);
}
}, [sel]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDropOver(true);
}, []);
const _handleDragLeave = useCallback(() => setDropOver(false), []);
const _handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder' && i.id !== node.id).map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) {
await onMoveFolders(folderIds, node.id);
} else if (onMoveFolder) {
for (const fId of folderIds) await onMoveFolder(fId, node.id);
}
if (fileIds.length > 0 && onMoveFiles) {
await onMoveFiles(fileIds, node.id);
} else if (fileIds.length > 0 && onMoveFile) {
for (const fId of fileIds) await onMoveFile(fId, node.id);
}
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileIdsJson = e.dataTransfer.getData('application/file-ids');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && folderId !== node.id && onMoveFolder) {
await onMoveFolder(folderId, node.id);
} else if (fileIdsJson && onMoveFiles) {
await onMoveFiles(JSON.parse(fileIdsJson), node.id);
} else if (fileId && onMoveFile) {
await onMoveFile(fileId, node.id);
}
}, [node.id, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const nodeClasses = [
styles.treeNode,
isNavSelected && !isMultiSelected ? styles.selected : '',
isMultiSelected ? styles.multiSelected : '',
dropOver ? styles.dropTarget : '',
dragging ? styles.dragging : '',
].filter(Boolean).join(' ');
const isProtected = node.isProtected === true;
const isReadonly = node.isReadonly === true;
const notDraggable = isProtected || isReadonly;
const notEditable = isProtected || isReadonly;
const customIcon = node.icon;
return (
<div>
<div
className={nodeClasses}
onClick={(e) => sel.onItemClick(node.id, 'folder', e)}
draggable={!notDraggable}
onDragStart={notDraggable ? undefined : (e) => {
sel.onItemDragStart(e, node.id, 'folder', node.name);
setDragging(true);
}}
onDragEnd={notDraggable ? undefined : () => setDragging(false)}
onDragOver={isProtected ? undefined : _handleDragOver}
onDragLeave={isProtected ? undefined : _handleDragLeave}
onDrop={isProtected ? undefined : _handleDrop}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''} ${!hasChildren ? styles.empty : ''}`}
onClick={(e) => { e.stopPropagation(); if (hasChildren) onToggle(node.id); }}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{customIcon ? (
<span style={{ fontSize: 14 }}>{customIcon}</span>
) : isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{renaming && !notEditable ? (
<input
ref={inputRef}
className={styles.renameInput}
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={_handleRename}
onKeyDown={(e) => {
if (e.key === 'Enter') _handleRename();
if (e.key === 'Escape') setRenaming(false);
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className={styles.folderName} style={notEditable ? { fontWeight: 600 } : undefined}>{node.name}</span>
)}
{!isProtected && (
<span className={styles.rightZone}>
<span className={styles.actions}>
{!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); onDownloadFolder(node.id, node.name); }} title={t('Ordner herunterladen (ZIP)')}>
<FaDownload />
</button>
)}
{onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={_handleAdd} title={t('Neuer Unterordner')}>
<FaPlus />
</button>
)}
{!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && (
<button className={styles.actionBtn} onClick={(e) => { e.stopPropagation(); setRenameValue(node.name); setRenaming(true); }} title={t('Umbenennen')}>
<FaPen />
</button>
)}
{isMultiSelected && sel.selectedItemIds.size > 1 ? (
<>
{sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFolders} title={`${sel.selectedFolderIds.length} ${t('Ordner löschen')}`}>
<FaFolder style={{ fontSize: 8, marginRight: 1 }} /><FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFolderIds.length}</span>
</button>
)}
{sel.selectedFileIds.length > 0 && sel.onDeleteFiles && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteFiles} title={`${sel.selectedFileIds.length} ${t('Dateien löschen')}`}>
<FaTrash />
<span style={{ fontSize: 9, marginLeft: 2, fontWeight: 600 }}>{sel.selectedFileIds.length}</span>
</button>
)}
</>
) : !notEditable && onDeleteFolder && (
<button className={`${styles.actionBtn} ${styles.danger}`} onClick={_handleDeleteSingle} title={t('Löschen')}>
<FaTrash />
</button>
)}
</span>
<_StableTrio
scope={node.scope}
neutralize={node.neutralize}
scopeLabels={scopeLabels}
onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined}
onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined}
onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined}
chatTitle={t('In Chat senden')}
/>
</span>
)}
</div>
{isExpanded && hasChildren && (
<div className={styles.children}>
{node.children!.map((child) => (
<_TreeNode
key={child.id}
node={child}
depth={depth + 1}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={onToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{folderFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
)}
</div>
);
}
/* ── Root component ────────────────────────────────────────────────────── */
export default function FolderTree({
folders, files, showFiles = false, selectedFolderId, onSelect, onFileSelect,
selectedItemIds: externalSelectedIds, onSelectionChange,
expandedIds: externalExpandedIds, onToggleExpand,
onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles,
onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder,
onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat,
}: FolderTreeProps) {
const { t } = useLanguage();
const [internalExpandedIds, setInternalExpandedIds] = useState<Set<string>>(new Set());
const [internalSelectedIds, setInternalSelectedIds] = useState<Set<string>>(new Set());
const lastClickedIdRef = useRef<string | null>(null);
const { prompt: promptFolderName, PromptDialog } = usePrompt();
const [rootDropOver, setRootDropOver] = useState(false);
const expandedIds = externalExpandedIds ?? internalExpandedIds;
const selectedItemIds = externalSelectedIds ?? internalSelectedIds;
const realTree = useMemo(() => _buildTree(folders), [folders]);
const filesByFolder = useMemo(() => _groupFilesByFolder(files || []), [files]);
const rootFiles = showFiles ? (filesByFolder.get('') || []) : [];
const knownFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(realTree);
return ids;
}, [realTree]);
const tree = useMemo(() => {
if (!showFiles) return realTree;
const orphanFolders: FolderNode[] = [];
for (const key of filesByFolder.keys()) {
if (key && !knownFolderIds.has(key)) {
orphanFolders.push({ id: key, name: key.slice(0, 8) + '…', parentId: null, fileCount: filesByFolder.get(key)?.length ?? 0, isProtected: true });
}
}
if (orphanFolders.length === 0) return realTree;
return [...realTree, ...orphanFolders.sort((a, b) => a.name.localeCompare(b.name))];
}, [realTree, showFiles, filesByFolder, knownFolderIds]);
const flatList = useMemo(
() => _computeFlatList(tree, expandedIds, showFiles, filesByFolder),
[tree, expandedIds, showFiles, filesByFolder],
);
const _handleToggle = useCallback((id: string) => {
if (onToggleExpand) {
onToggleExpand(id);
return;
}
setInternalExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, [onToggleExpand]);
const _setSelection = useCallback((ids: Set<string>) => {
if (onSelectionChange) {
onSelectionChange(ids);
} else {
setInternalSelectedIds(ids);
}
}, [onSelectionChange]);
const _handleItemClick = useCallback((id: string, type: 'file' | 'folder', e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
const next = new Set(selectedItemIds);
if (next.has(id)) next.delete(id); else next.add(id);
_setSelection(next);
lastClickedIdRef.current = id;
return;
}
if (e.shiftKey && lastClickedIdRef.current) {
const lastIdx = flatList.findIndex(i => i.id === lastClickedIdRef.current);
const currIdx = flatList.findIndex(i => i.id === id);
if (lastIdx >= 0 && currIdx >= 0) {
const [from, to] = lastIdx < currIdx ? [lastIdx, currIdx] : [currIdx, lastIdx];
const next = new Set(selectedItemIds);
for (let i = from; i <= to; i++) next.add(flatList[i].id);
_setSelection(next);
}
return;
}
_setSelection(new Set([id]));
lastClickedIdRef.current = id;
if (type === 'folder') onSelect(id);
if (type === 'file') onFileSelect?.(id);
}, [selectedItemIds, flatList, _setSelection, onSelect, onFileSelect]);
const _handleItemDragStart = useCallback((e: React.DragEvent, id: string, type: 'file' | 'folder', name: string) => {
const isInSelection = selectedItemIds.has(id) && selectedItemIds.size > 1;
if (isInSelection) {
const items: TreeItem[] = [];
for (const selId of selectedItemIds) {
const item = flatList.find(i => i.id === selId);
if (item) items.push(item);
}
e.dataTransfer.setData('application/tree-items', JSON.stringify(items));
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
if (fileIds.length > 0) {
e.dataTransfer.setData('application/file-ids', JSON.stringify(fileIds));
}
} else {
e.dataTransfer.setData('application/tree-items', JSON.stringify([{ id, type, name }]));
if (type === 'file') {
e.dataTransfer.setData('application/file-id', id);
} else {
e.dataTransfer.setData('application/folder-id', id);
}
}
e.dataTransfer.effectAllowed = 'copyMove';
}, [selectedItemIds, flatList]);
const allFileIds = useMemo(() => {
const ids = new Set<string>();
for (const [, arr] of filesByFolder) for (const f of arr) ids.add(f.id);
return ids;
}, [filesByFolder]);
const allFolderIds = useMemo(() => {
const ids = new Set<string>();
const _collect = (nodes: FolderNode[]) => { for (const n of nodes) { ids.add(n.id); if (n.children) _collect(n.children); } };
_collect(tree);
return ids;
}, [tree]);
const sel: SelectionCtx = useMemo(() => {
const selFileIds = Array.from(selectedItemIds).filter(id => allFileIds.has(id));
const selFolderIds = Array.from(selectedItemIds).filter(id => allFolderIds.has(id));
return {
selectedItemIds,
selectedFileIds: selFileIds,
selectedFolderIds: selFolderIds,
onItemClick: _handleItemClick,
onItemDragStart: _handleItemDragStart,
onRenameFile,
onDeleteFile,
onDeleteFiles,
onDeleteFolders,
onScopeChange,
onNeutralizeToggle,
onSendToChat,
};
}, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle, onSendToChat]);
// Root drop handler: items dropped on the empty area go to root (null)
const _handleRootDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
setRootDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
const items: TreeItem[] = JSON.parse(treeItemsJson);
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
const folderIds = items.filter(i => i.type === 'folder').map(i => i.id);
if (folderIds.length > 0 && onMoveFolders) await onMoveFolders(folderIds, null);
else if (onMoveFolder) for (const fId of folderIds) await onMoveFolder(fId, null);
if (fileIds.length > 0 && onMoveFiles) await onMoveFiles(fileIds, null);
else if (onMoveFile) for (const fId of fileIds) await onMoveFile(fId, null);
return;
}
const folderId = e.dataTransfer.getData('application/folder-id');
const fileId = e.dataTransfer.getData('application/file-id');
if (folderId && onMoveFolder) await onMoveFolder(folderId, null);
else if (fileId && onMoveFile) await onMoveFile(fileId, null);
}, [onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles]);
const _handleRootAddFolder = useCallback(async () => {
if (!onCreateFolder) return;
const name = await promptFolderName(t('Neuer Ordnername:'), { title: t('Neuer Ordner'), placeholder: t('Ordnername') });
if (name?.trim()) await onCreateFolder(name.trim(), null);
}, [onCreateFolder, promptFolderName, t]);
const isRootSelected = selectedFolderId === null;
const _handleRootClick = useCallback(() => {
_setSelection(new Set());
onSelect(null);
}, [_setSelection, onSelect]);
return (
<div className={styles.folderTree}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '2px 4px' }}>
<span
className={`${styles.treeNode} ${isRootSelected ? styles.selected : ''} ${rootDropOver ? styles.dropTarget : ''}`}
style={{ flex: 1, cursor: 'pointer', fontWeight: 600, paddingLeft: 4 }}
onClick={_handleRootClick}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setRootDropOver(true); }}
onDragLeave={() => setRootDropOver(false)}
onDrop={_handleRootDrop}
>
/
</span>
<span className={styles.actions}>
{onCreateFolder && (
<button className={styles.actionBtn} onClick={_handleRootAddFolder} title={t('Neuer Ordner')}>
<FaPlus />
</button>
)}
{onRefresh && (
<button className={styles.actionBtn} onClick={onRefresh} title={t('Aktualisieren')}>
<FaSyncAlt />
</button>
)}
</span>
</div>
<div className={styles.children}>
{tree.map((node) => (
<_TreeNode
key={node.id}
node={node}
depth={0}
selectedFolderId={selectedFolderId}
expandedIds={expandedIds}
showFiles={showFiles}
filesByFolder={filesByFolder}
sel={sel}
promptFolderName={promptFolderName}
onToggle={_handleToggle}
onSelect={onSelect}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
onMoveFolder={onMoveFolder}
onMoveFolders={onMoveFolders}
onMoveFile={onMoveFile}
onMoveFiles={onMoveFiles}
onDownloadFolder={onDownloadFolder}
onFolderScopeChange={onFolderScopeChange}
onFolderNeutralizeToggle={onFolderNeutralizeToggle}
/>
))}
{rootFiles.map((file) => (
<_FileItem key={file.id} file={file} sel={sel} />
))}
</div>
<PromptDialog />
</div>
);
}

View file

@ -1,319 +0,0 @@
/**
* SharepointBrowseTree Lazy-loading tree for SharePoint browse.
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
* Loads children on expand via onLoadChildren(path).
*/
import React, { useState, useCallback, useEffect } from 'react';
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
import styles from './FolderTree.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export interface SharepointBrowseTreeProps {
/** Root path (usually "/") - children loaded via onLoadChildren */
rootPath?: string;
/** Load children for a given path. Returns folders and files. */
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
/** Called when user selects a file path */
onSelectFile: (path: string) => void;
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
onSelectFolder?: (path: string) => void;
/** If true, file rows are not shown — only folders (for list/upload/destination folder pickers). */
foldersOnly?: boolean;
/** Currently selected path (for highlight) */
selectedPath?: string | null;
/** Optional: pre-seed root children (e.g. from initial load) */
initialChildren?: BrowseEntry[];
}
function _fileIcon(mime?: string): string {
if (!mime) return '\uD83D\uDCC4';
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
if (mime.includes('pdf')) return '\uD83D\uDCD5';
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
return '\uD83D\uDCC4';
}
/* ── File row ──────────────────────────────────────────────────────────── */
function _FileRow({
entry,
selectedPath,
onSelect,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
onSelect: (path: string) => void;
}) {
const isSelected = selectedPath === entry.path;
return (
<div
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
onClick={() => onSelect(entry.path)}
title={entry.path}
>
<span className={styles.chevron + ' ' + styles.empty} />
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
<span className={styles.folderName}>{entry.name}</span>
{entry.size != null && (
<span className={styles.fileSize}>
{(entry.size / 1024).toFixed(0)}K
</span>
)}
</div>
);
}
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
function _FolderRow({
entry,
selectedPath,
expandedPaths,
loadedChildren,
loadingPaths,
onToggle,
onSelectFile,
onSelectFolder,
foldersOnly,
}: {
entry: BrowseEntry;
selectedPath: string | null | undefined;
expandedPaths: Set<string>;
loadedChildren: Record<string, BrowseEntry[]>;
loadingPaths: Set<string>;
onToggle: (path: string) => void;
onSelectFile: (path: string) => void;
onSelectFolder?: (path: string) => void;
foldersOnly: boolean;
}) {
const { t } = useLanguage();
const isExpanded = expandedPaths.has(entry.path);
const isSelected = selectedPath === entry.path;
const children = loadedChildren[entry.path] ?? [];
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const isLoading = isExpanded && loadingPaths.has(entry.path);
const handleRowClick = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (target.closest(`.${styles.chevron}`)) return;
if (onSelectFolder) {
onSelectFolder(entry.path);
return;
}
onToggle(entry.path);
};
const handleChevronClick = (e: React.MouseEvent) => {
e.stopPropagation();
onToggle(entry.path);
};
return (
<div>
<div
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
onClick={handleRowClick}
title={entry.path}
>
<span
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
onClick={handleChevronClick}
title={isExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
<span className={styles.folderName}>{entry.name}</span>
{isLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isExpanded && (
<div className={styles.children}>
{isLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Wird geladen…')}
</div>
) : (
<>
{folders.map((child) => (
<_FolderRow
key={child.path}
entry={child}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={onToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{!foldersOnly &&
files.map((child) => (
<_FileRow
key={child.path}
entry={child}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{children.length === 0 && (
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Leer')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}
/* ── Root component ─────────────────────────────────────────────────────── */
export function SharepointBrowseTree({
rootPath = '/',
onLoadChildren,
onSelectFile,
onSelectFolder,
foldersOnly = false,
selectedPath,
initialChildren = [],
}: SharepointBrowseTreeProps) {
const { t } = useLanguage();
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
);
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
const loadPath = useCallback(
async (path: string) => {
setLoadingPaths((p) => new Set(p).add(path));
try {
const items = await onLoadChildren(path);
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
} catch {
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
} finally {
setLoadingPaths((p) => {
const next = new Set(p);
next.delete(path);
return next;
});
}
},
[onLoadChildren]
);
const handleToggle = useCallback(
(path: string) => {
setExpandedPaths((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
loadPath(path);
}
return next;
});
},
[loadPath]
);
useEffect(() => {
if (rootPath in loadedChildren) return;
if (initialChildren.length > 0) return;
loadPath(rootPath);
}, [rootPath, initialChildren.length, loadPath]);
const rootItems = loadedChildren[rootPath] ?? [];
const rootLoading = loadingPaths.has(rootPath);
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
const isRootExpanded = expandedPaths.has(rootPath);
return (
<div className={styles.folderTree}>
<div
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
style={{ fontWeight: 600 }}
>
<span
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
onClick={() => handleToggle(rootPath)}
title={isRootExpanded ? t('Einklappen') : t('Erweitern')}
>
<FaChevronRight />
</span>
<span className={styles.folderIcon}><FaGlobe /></span>
<span className={`${styles.folderName} ${styles.rootLabel}`}>{t('SharePoint')}</span>
{rootLoading && (
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}></span>
)}
</div>
{isRootExpanded && (
<div className={styles.children}>
{rootLoading ? (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
{t('Sites werden geladen…')}
</div>
) : (
<>
{rootFolders.map((entry) => (
<_FolderRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
expandedPaths={expandedPaths}
loadedChildren={loadedChildren}
loadingPaths={loadingPaths}
onToggle={handleToggle}
onSelectFile={onSelectFile}
onSelectFolder={onSelectFolder}
foldersOnly={foldersOnly}
/>
))}
{!foldersOnly &&
rootFiles.map((entry) => (
<_FileRow
key={entry.path}
entry={entry}
selectedPath={selectedPath}
onSelect={onSelectFile}
/>
))}
{rootItems.length === 0 && !rootLoading && (
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
{t('Keine Einträge')}
</div>
)}
</>
)}
</div>
)}
</div>
);
}

View file

@ -247,6 +247,34 @@
box-shadow: 0 3px 8px rgba(197, 48, 48, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1) !important;
}
/* Compact mode (sidebar/UDB) */
.compact {
width: 20px !important;
height: 20px !important;
min-width: 0 !important;
min-height: 0 !important;
padding: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: var(--color-text-secondary, #6b7280) !important;
border-radius: 3px !important;
flex-shrink: 0;
}
.compact .actionIcon {
font-size: 12px !important;
width: 12px !important;
height: 12px !important;
filter: none !important;
}
.compact:hover {
background: var(--color-secondary, #4A6FA5) !important;
color: #fff !important;
box-shadow: none !important;
transform: none !important;
}
/* Responsive Design */
@media (max-width: 768px) {
.actionButtons {

View file

@ -10,7 +10,7 @@ import type { AttributeType } from '../../../utils/attributeTypeMapper';
// Generic field/column config interface
export interface FilterableField {
key: string;
label: string;
label?: string;
type?: AttributeType;
filterable?: boolean;
filterOptions?: string[];
@ -179,9 +179,15 @@ export function FormGeneratorControls({
</div>
)}
{/* Search Controls with Pagination - Hide when items are selected */}
{searchable && selectedCount === 0 && (
{/* Toolbar: optional search + filters badge + CSV + pagination (search is optional) */}
{selectedCount === 0 &&
(searchable ||
(pagination && supportsBackendPagination) ||
!!onCsvExport ||
!!onRefresh ||
activeFiltersCount > 0) && (
<div className={styles.searchContainer}>
{searchable && (
<div className={styles.floatingLabelInput}>
<input
type="text"
@ -196,6 +202,7 @@ export function FormGeneratorControls({
{t('Suchen...')}
</label>
</div>
)}
{activeFiltersCount > 0 && (
<span className={styles.activeFiltersCount}>
{activeFiltersCount} {t('Filter')}

View file

@ -109,6 +109,53 @@
border-color: var(--primary-color, #f25843);
}
/* --- Multiselect chip group --- */
.chipGroup {
display: inline-flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.625rem;
border: 1px solid var(--border-color, #333);
border-radius: 999px;
background: var(--bg-secondary, #2a2a2a);
color: var(--text-secondary, #888);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
user-select: none;
transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}
.chip:hover {
border-color: var(--primary-color, #f25843);
color: var(--text-primary, #e0e0e0);
}
.chipActive {
background: var(--primary-color, #4A6FA5);
border-color: var(--primary-color, #4A6FA5);
color: #fff;
}
.chipActive:hover {
color: #fff;
filter: brightness(0.95);
}
.chipMeta {
font-size: 0.7rem;
color: var(--text-secondary, #888);
margin-left: 0.25rem;
}
/* --- Sections Grid --- */
.sectionsGrid {

View file

@ -6,6 +6,13 @@ import {
} from 'recharts';
import styles from './FormGeneratorReport.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
import {
PeriodPicker,
fromIsoDate,
toIsoDate,
type PeriodPreset,
type PeriodValue,
} from '../../PeriodPicker';
import type {
FormGeneratorReportProps,
@ -531,14 +538,36 @@ const _Toolbar: React.FC<ToolbarProps> = ({
});
};
const _handleDateRangeChange = (field: 'from' | 'to', dateStr: string) => {
const dateRange = filterState.dateRange || { from: new Date(), to: new Date() };
const _handlePeriodPickerChange = (next: PeriodValue) => {
const fromD = fromIsoDate(next.fromDate) || new Date();
const toD = fromIsoDate(next.toDate) || new Date();
onFilterStateChange({
...filterState,
dateRange: { ...dateRange, [field]: new Date(dateStr) }
dateRange: { from: fromD, to: toD },
periodValue: next,
});
};
// Prefer the preserved PeriodValue (carries the preset) so the round-trip
// back into PeriodPicker does not collapse to `custom`, which would clash
// with `direction: 'past'` for presets whose natural end is in the future
// (e.g. `thisMonth`, `thisQuarter`, `ytd`) and trigger an infinite fallback
// loop in PeriodPicker's constraint-correction effect.
const _periodPickerValue: PeriodValue | null = useMemo(() => {
if (filterState.periodValue) return filterState.periodValue;
const dr = filterState.dateRange;
if (!dr?.from || !dr?.to) return null;
return {
preset: { kind: 'custom' },
fromDate: toIsoDate(dr.from),
toDate: toIsoDate(dr.to),
};
}, [filterState.periodValue, filterState.dateRange]);
const _periodPickerDefault: PeriodPreset = useMemo(() => {
return { kind: dateRangeSelector?.defaultPresetKind || 'ytd' } as PeriodPreset;
}, [dateRangeSelector?.defaultPresetKind]);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 5 }, (_, i) => currentYear - i);
@ -605,22 +634,18 @@ const _Toolbar: React.FC<ToolbarProps> = ({
<div className={styles.toolbarSeparator} />
)}
{/* Date Range */}
{/* Date Range (rendered via shared PeriodPicker) */}
{hasDateRange && (
<div className={styles.toolbarGroup}>
<span className={styles.toolbarLabel}>{t('Von')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.from?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('from', e.target.value)}
/>
<span className={styles.toolbarLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.dateInput}
value={filterState.dateRange?.to?.toISOString().split('T')[0] || ''}
onChange={(e) => _handleDateRangeChange('to', e.target.value)}
<span className={styles.toolbarLabel}>{t('Zeitraum')}</span>
<PeriodPicker
value={_periodPickerValue}
onChange={_handlePeriodPickerChange}
direction={dateRangeSelector!.direction || 'any'}
defaultPreset={_periodPickerDefault}
enabledPresets={dateRangeSelector!.enabledPresets}
minDate={dateRangeSelector!.minDate}
maxDate={dateRangeSelector!.maxDate}
/>
</div>
)}
@ -642,6 +667,12 @@ const _Toolbar: React.FC<ToolbarProps> = ({
value={(filterState.filters[filter.key] as string) || ''}
onChange={(e) => _handleFilterChange(filter.key, e.target.value)}
/>
) : filter.type === 'multiselect' ? (
<_MultiselectChips
filter={filter}
value={filterState.filters[filter.key]}
onChange={(next) => _handleFilterChange(filter.key, next)}
/>
) : (
<select
className={styles.select}
@ -660,6 +691,73 @@ const _Toolbar: React.FC<ToolbarProps> = ({
);
};
// =============================================================================
// MULTISELECT CHIPS
// Renders ``multiselect`` filters as inline toggle chips so the user can:
// - see at a glance which values are active
// - toggle individual values on/off
// - reset to "all" with the leading "Alle"-chip
// Emits the selection upstream as a ``string[]`` matching ``ReportFilterState``.
// =============================================================================
interface _MultiselectChipsProps {
filter: ReportFilterConfig;
value: string | string[] | undefined;
onChange: (next: string[]) => void;
}
const _MultiselectChips: React.FC<_MultiselectChipsProps> = ({ filter, value, onChange }) => {
const { t } = useLanguage();
const selected: Set<string> = useMemo(() => {
if (Array.isArray(value)) return new Set(value.map(String));
if (typeof value === 'string' && value !== '') return new Set([value]);
return new Set<string>();
}, [value]);
const _toggle = (optValue: string) => {
const next = new Set(selected);
if (next.has(optValue)) next.delete(optValue); else next.add(optValue);
onChange(Array.from(next));
};
const _reset = () => onChange([]);
const allLabel = filter.placeholder || t('Alle');
const totalActive = selected.size;
return (
<div className={styles.chipGroup}>
<button
type="button"
className={`${styles.chip} ${totalActive === 0 ? styles.chipActive : ''}`}
onClick={_reset}
title={allLabel}
>
{allLabel}
</button>
{filter.options?.map(opt => {
const active = selected.has(opt.value);
return (
<button
key={opt.value}
type="button"
className={`${styles.chip} ${active ? styles.chipActive : ''}`}
onClick={() => _toggle(opt.value)}
title={opt.label}
>
{opt.label}
</button>
);
})}
{totalActive > 0 && (
<span className={styles.chipMeta}>
{t('{n} aktiv', { n: String(totalActive) })}
</span>
)}
</div>
);
};
// =============================================================================
// MAIN COMPONENT
// =============================================================================

View file

@ -54,7 +54,7 @@ export interface ReportPeriodSelectorConfig {
defaultMonth?: number;
}
/** Date range selector configuration */
/** Date range selector configuration. Renders the shared PeriodPicker. */
export interface ReportDateRangeSelectorConfig {
/** Whether the date range selector is enabled */
enabled: boolean;
@ -62,6 +62,28 @@ export interface ReportDateRangeSelectorConfig {
defaultFrom?: Date;
/** Default to date */
defaultTo?: Date;
/**
* Allowed direction relative to today. Default: `'any'`. Set to `'past'`
* for historic reports (most cases), `'future'` for forecasts.
*/
direction?: 'past' | 'future' | 'any';
/**
* Default preset kind shown when neither `defaultFrom`/`defaultTo` nor a
* stored selection is available. Default: `'ytd'`.
*/
defaultPresetKind?:
| 'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter' | 'custom';
/** Whitelist of preset kinds offered to the user. */
enabledPresets?: Array<
'allTime' | 'ytd' | 'lastYear' | 'nextYear' | 'last12Months' | 'next12Months'
| 'thisMonth' | 'lastMonth' | 'thisQuarter' | 'lastQuarter'
| 'lastN' | 'nextN' | 'custom'
>;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
minDate?: string;
/** Min/max boundaries (ISO `YYYY-MM-DD`). */
maxDate?: string;
}
/** Combined filter state passed to the data callback */
@ -72,8 +94,15 @@ export interface ReportFilterState {
year?: number;
/** Selected month (1-12) */
month?: number;
/** Date range */
/** Date range (always synthesized from `periodValue` when the
* `dateRangeSelector` is enabled). */
dateRange?: ReportDateRange;
/**
* Full PeriodPicker value when the `dateRangeSelector` is enabled. Carries
* the original preset (e.g. `thisMonth`) so that the round-trip back into
* the picker preserves preset semantics and does not collapse to `custom`.
*/
periodValue?: import('../../PeriodPicker').PeriodValue;
/** Custom filter values: key -> value(s) */
filters: Record<string, string | string[]>;
}

View file

@ -9,6 +9,13 @@
overflow: hidden;
height: 100%;
max-height: 100%;
position: relative;
}
/* Outer table in “sections” mode: fill flex parent (e.g. billing transactions tab) */
.formGeneratorTableSectionsRoot {
flex: 1;
min-height: 0;
}
.title {
@ -78,6 +85,93 @@
padding: 40px 20px;
}
/* ── Group sections layout (one table per category) ───────────────────── */
.groupSections {
display: flex;
flex-direction: column;
gap: 1.25rem;
width: 100%;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.groupSection {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
/* Share remaining viewport among expanded groups; scroll when many groups */
flex: 1 1 280px;
min-height: 0;
}
.groupSectionCollapsed {
flex: 0 0 auto;
min-height: unset;
}
.groupSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
margin: 0;
padding: 8px 4px 4px;
border: none;
border-bottom: 1px solid var(--color-border, #e2e8f0);
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
color: inherit;
border-radius: 4px 4px 0 0;
}
.groupSectionHeader:hover {
background: color-mix(in srgb, var(--color-bg, #fff) 92%, var(--color-border, #e2e8f0) 8%);
}
.groupSectionHeaderLeft {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.groupSectionCaret {
font-size: 11px;
opacity: 0.65;
width: 14px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.groupSectionTitle {
font-weight: 600;
font-size: 1rem;
color: var(--color-text, inherit);
}
.groupSectionMeta {
font-size: 0.875rem;
color: var(--text-muted, #64748b);
}
.groupSectionsLoading {
padding: 12px 4px;
color: var(--text-muted, #64748b);
}
.groupSectionTableWrap {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
.emptyMessage {
text-align: center;
padding: 20px;
@ -133,20 +227,26 @@
}
.table thead tr {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
.th {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
padding: 10px 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--color-text-secondary, #64748b);
font-size: 11px;
letter-spacing: 0.02em;
color: var(--color-text-secondary, #475569);
white-space: nowrap;
overflow: visible;
user-select: none;
border-bottom: 2px solid var(--color-border, #e2e8f0);
border-bottom: 2px solid rgba(124, 109, 216, 0.35);
border-right: 1px solid #dde2ea;
}
.th:last-child {
border-right: none;
}
.th.actionsColumn {
@ -159,14 +259,13 @@
}
.th.sortable:hover {
background: #eef0f3;
background: #e4e8ef;
color: var(--color-text, #334155);
}
.headerContent {
display: flex;
align-items: center;
justify-content: left;
gap: 4px;
}
@ -230,8 +329,8 @@
position: absolute;
top: 100%;
left: 0;
min-width: 180px;
max-width: 300px;
min-width: 200px;
max-width: 320px;
background: var(--color-bg);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
@ -303,6 +402,116 @@
font-style: italic;
}
/* Numeric column filter (operator + value / range) */
.filterNumericPanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterNumericRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterNumericLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterOperatorSelect,
.filterNumericInput {
width: 100%;
padding: 6px 8px;
font-size: 13px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterOperatorSelect:focus,
.filterNumericInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.filterNumericActions {
padding-top: 2px;
}
.filterApplyBtn {
width: 100%;
padding: 6px 10px;
font-size: 12px;
font-weight: 600;
font-family: var(--font-family);
cursor: pointer;
border: none;
border-radius: 6px;
background: var(--color-secondary);
color: #fff;
}
.filterApplyBtn:hover {
opacity: 0.92;
}
/* PeriodPicker wrapper inside filter dropdown (date columns).
Rendered as sibling to .filterDropdownOptions so the PeriodPicker
popover (position: absolute, ~720 px) is not clipped by overflow. */
.filterDatePickerWrap {
padding: 6px 8px 8px;
overflow: visible;
}
.filterDatePickerWrap + .filterDropdownOptions {
display: none;
}
/* Date column filter (from / to) — legacy fallback */
.filterDatePanel {
padding: 6px 8px 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.filterDateRow {
display: flex;
flex-direction: column;
gap: 4px;
}
.filterDateLabel {
font-size: 11px;
font-weight: 500;
color: var(--color-text-secondary, #64748b);
}
.filterDateInput {
width: 100%;
padding: 6px 8px;
font-size: 12px;
font-family: var(--font-family);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text);
box-sizing: border-box;
}
.filterDateInput:focus {
outline: none;
border-color: var(--color-secondary);
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
}
.resizeHandle {
position: absolute;
top: 0;
@ -326,7 +535,8 @@
/* Table cells */
.td {
padding: 8px 12px;
border-top: 1px solid var(--color-border, #f1f5f9);
border-top: 1px solid var(--color-border, #e5e9ef);
border-right: 1px solid #eef0f4;
color: var(--color-text);
font-weight: 400;
font-size: 13px;
@ -338,27 +548,27 @@
overflow: visible;
}
.fkLoading {
color: var(--color-text);
opacity: 0.6;
font-style: italic;
.td:last-child {
border-right: none;
}
/* Rows */
.tr {
transition: background-color 0.12s ease;
transition: background-color 0.12s ease, box-shadow 0.12s ease;
}
.tr:hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(0, 0, 0, 0.015);
background: rgba(0, 0, 0, 0.025);
}
.tr:nth-child(even):hover {
background: var(--color-gray-disabled, #f8fafc);
background: #f0f4ff;
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {
@ -369,6 +579,56 @@
cursor: pointer;
}
/* Items that live inside a group — subtle tint + left connector */
.tr.groupedItem {
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
}
.tr.groupedItem:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-bg, #fff));
}
/**
* Hierarchy: set `--row-tree-indent` on the <tr> (px). Same row shifts checkbox, actions, and every `.td`.
* Folder rows attach this class from GroupRow.tsx; omit padding on `.folderCell` (inner strip uses `--group-indent`).
*/
.treeRowIndented {
--row-tree-indent: 0px;
}
.treeRowIndented > .selectColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .actionsColumn {
box-sizing: border-box !important;
padding-top: 4px !important;
padding-right: 4px !important;
padding-bottom: 4px !important;
padding-left: calc(4px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .td {
box-sizing: border-box !important;
padding-top: 8px !important;
padding-right: 12px !important;
padding-bottom: 8px !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .folderCell:first-child {
box-sizing: border-box !important;
padding-left: calc(12px + var(--row-tree-indent)) !important;
}
.treeRowIndented > .selectColumn + .folderCell {
padding: 0 !important;
}
/* Selection Column */
.selectColumn {
text-align: center;
@ -378,7 +638,7 @@
}
thead .selectColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .selectColumn {
@ -429,7 +689,7 @@ tbody .selectColumn {
}
thead .actionsColumn {
background: var(--table-header-bg, #f8f9fa);
background: var(--table-header-bg, #edf0f5);
}
tbody .actionsColumn {
@ -714,7 +974,11 @@ tbody .actionsColumn {
height: auto;
}
.th,
.th {
padding: 6px 8px;
font-size: 10px;
}
.td {
padding: 6px 8px;
font-size: 12px;
@ -764,29 +1028,40 @@ tbody .actionsColumn {
/* Dark theme */
@media (prefers-color-scheme: dark) {
.table thead tr {
background: #2a2d31;
background: #2d3038;
}
.th {
background: #2a2d31;
border-bottom-color: rgba(255, 255, 255, 0.12);
background: #2d3038;
border-bottom: 2px solid rgba(124, 109, 216, 0.3);
border-right-color: rgba(255, 255, 255, 0.08);
}
.td {
border-right-color: rgba(255, 255, 255, 0.06);
}
thead .selectColumn,
thead .actionsColumn {
background: #2a2d31;
background: #2d3038;
}
.th.sortable:hover {
background: #32363b;
background: #363a42;
}
.tr:hover {
background: rgba(255, 255, 255, 0.04);
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
background: rgba(255, 255, 255, 0.03);
}
.tr:nth-child(even):hover {
background: rgba(124, 109, 216, 0.08);
box-shadow: inset 3px 0 0 0 var(--color-secondary);
}
.tr.selected {
@ -990,3 +1265,134 @@ tbody .actionsColumn {
gap: 4px;
align-items: center;
}
/* ── Compact sidebar mode ───────────────────────────────────────────────────── */
.compactMode {
gap: 0;
}
.compactMode .tableWrapper {
border: none;
}
/* Switch to auto layout so the action column shrinks to its content width
and the name column fills all remaining space naturally */
.compactMode .table {
table-layout: auto;
}
.compactMode .td {
padding: 5px 8px;
font-size: 12px;
border-right: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
/* Let the browser size this column based on content */
width: auto;
min-width: unset;
max-width: unset;
}
/* Re-apply tree indent for data cells in compact mode */
.compactMode .treeRowIndented > .td {
padding-top: 5px !important;
padding-right: 8px !important;
padding-bottom: 5px !important;
padding-left: calc(8px + var(--row-tree-indent)) !important;
}
/* The action column: fixed narrow width, no background strip */
.compactMode .actionsColumn {
width: 28px !important;
min-width: 0 !important;
max-width: 28px !important;
padding: 2px !important;
background: transparent !important;
overflow: hidden;
white-space: nowrap;
}
.compactMode .actionButtons {
display: inline-flex !important;
width: auto !important;
gap: 0 !important;
justify-content: center;
}
/* Re-apply tree indent for action column in compact mode (overrides the default padding above) */
.compactMode .treeRowIndented > .actionsColumn {
padding: 2px !important;
}
/* Tighten group rows in compact mode */
.compactMode :global(.groupRow) {
font-size: 11px;
padding: 4px 8px;
}
/* Group bands (server-side view grouping — ClickUp-style) */
.groupBandHeaderRow {
cursor: pointer;
user-select: none;
background: color-mix(in srgb, var(--color-bg, #fff) 88%, var(--color-border, #e2e8f0) 12%);
}
.groupBandHeaderCell {
padding: 8px 14px !important;
border-bottom: 1px solid var(--color-border, #e2e8f0);
vertical-align: middle;
}
.groupBandInner {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 600;
color: var(--color-text, #0f172a);
}
.groupBandCaret {
font-size: 11px;
opacity: 0.65;
width: 14px;
flex-shrink: 0;
transition: transform 0.15s ease;
}
.groupBandPill {
display: inline-flex;
align-items: center;
max-width: min(420px, 72%);
padding: 5px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
line-height: 1.25;
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 16%, transparent);
color: color-mix(in srgb, var(--color-primary, #2f4364) 95%, #fff);
border: 1px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 32%, transparent);
}
.groupBandPath {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
opacity: 0.92;
}
.groupBandPathSep {
opacity: 0.45;
font-weight: 500;
}
.groupBandCount {
margin-left: auto;
font-weight: 500;
font-size: 12px;
opacity: 0.5;
flex-shrink: 0;
}

View file

@ -0,0 +1,677 @@
.formGeneratorTree {
display: flex;
flex-direction: column;
width: 100%;
font-family: var(--font-family);
min-height: 0;
flex: 1;
overflow: hidden;
height: 100%;
max-height: 100%;
position: relative;
}
/* Section header */
.sectionHeader {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-bottom: 1px solid var(--color-border, #e2e8f0);
background: var(--table-header-bg, #edf0f5);
border-radius: 8px 8px 0 0;
}
.sectionHeader:hover {
background: #e4e8ef;
}
.sectionHeaderNonCollapsible {
cursor: default;
}
.sectionHeaderNonCollapsible:hover {
background: var(--table-header-bg, #edf0f5);
}
.collapseChevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
font-size: 10px;
color: var(--color-text-secondary, #64748b);
transition: transform 0.2s ease;
flex-shrink: 0;
}
.collapseChevronExpanded {
transform: rotate(90deg);
}
.sectionTitle {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-text-secondary, #475569);
flex: 1;
}
.sectionCount {
font-size: 11px;
font-weight: 400;
color: var(--color-text-secondary, #94a3b8);
}
.refreshBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary, #94a3b8);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
margin-left: 2px;
flex-shrink: 0;
}
.refreshBtn:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155);
}
/* Filter row */
.filterRow {
display: flex;
align-items: center;
padding: 4px 8px;
gap: 4px;
position: relative;
}
.filterInput {
flex: 1;
padding: 4px 24px 4px 8px;
font-size: 12px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 4px;
background: var(--color-bg, #fff);
color: var(--color-text, #334155);
outline: none;
}
.filterInput:focus {
border-color: var(--primary-color, #F25843);
box-shadow: 0 0 0 1px var(--primary-color, #F25843);
}
.filterInput::placeholder {
color: var(--color-text-muted, #94a3b8);
}
.filterClear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: var(--color-text-muted, #94a3b8);
padding: 0 2px;
line-height: 1;
}
.filterClear:hover {
color: var(--color-text, #334155);
}
/* Tree wrapper */
.treeWrapper {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
background: var(--color-bg);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
/* Tree content (scrollable) */
.treeContent {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.treeContent::-webkit-scrollbar {
width: 6px;
}
.treeContent::-webkit-scrollbar-track {
background: transparent;
}
.treeContent::-webkit-scrollbar-thumb {
background: var(--color-border, #cbd5e1);
border-radius: 3px;
}
.treeContent::-webkit-scrollbar-thumb:hover {
background: var(--color-text-secondary, #94a3b8);
}
/* Batch action toolbar */
.batchToolbar {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--table-header-bg, #edf0f5);
border-bottom: 1px solid var(--color-border, #e2e8f0);
flex-shrink: 0;
flex-wrap: wrap;
}
.batchCount {
font-size: 12px;
font-weight: 500;
color: var(--color-text-secondary, #475569);
margin-right: 4px;
white-space: nowrap;
}
.batchButton {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
background: var(--color-bg);
color: var(--color-text, #334155);
font-size: 12px;
font-family: var(--font-family);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.batchButton:hover {
background: var(--color-gray-disabled, #f1f5f9);
border-color: var(--color-text-secondary, #94a3b8);
}
.batchButtonDanger {
color: #dc2626;
border-color: rgba(220, 38, 38, 0.3);
}
.batchButtonDanger:hover {
background: rgba(220, 38, 38, 0.06);
border-color: #dc2626;
}
.batchButtonIcon {
font-size: 12px;
display: inline-flex;
}
.batchButtonCount {
font-size: 10px;
font-weight: 700;
margin-left: 2px;
opacity: 0.8;
}
/* Node row */
.nodeRow {
display: flex;
align-items: center;
gap: 4px;
height: 36px;
padding: 0 8px;
cursor: pointer;
user-select: none;
transition: background-color 0.12s ease;
position: relative;
border-bottom: 1px solid transparent;
}
.nodeRowCompact {
height: 32px;
}
.nodeRow:hover {
background: #f0f4ff;
}
.nodeRowSelected {
background: rgba(var(--color-secondary-rgb), 0.08);
}
.nodeRowSelected:hover {
background: rgba(var(--color-secondary-rgb), 0.12);
}
.nodeRowFocused {
box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
border-radius: 2px;
}
.nodeRowDragOver {
background: rgba(var(--color-secondary-rgb), 0.06);
border: 1px dashed var(--color-secondary);
border-radius: 4px;
}
.nodeRowDragging {
opacity: 0.5;
}
.nodeRowOrphan {
border-left: 2px solid #f59e0b;
}
/* Indent spacer */
.indentSpacer {
flex-shrink: 0;
}
/* Checkbox */
.nodeCheckbox {
flex-shrink: 0;
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-secondary);
margin: 0;
}
/* Expand/collapse chevron */
.expandChevron {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 10px;
color: var(--color-text-secondary, #64748b);
cursor: pointer;
flex-shrink: 0;
border-radius: 3px;
transition: transform 0.15s ease, background 0.15s ease;
}
.expandChevron:hover {
background: rgba(0, 0, 0, 0.06);
}
.expandChevronExpanded {
transform: rotate(90deg);
}
.expandChevronPlaceholder {
width: 18px;
flex-shrink: 0;
}
/* Node icon */
.nodeIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 14px;
color: var(--color-text-secondary, #64748b);
flex-shrink: 0;
}
/* Node name */
.nodeName {
flex: 1;
font-size: 13px;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* File size + hover actions group (overlapping layout to save width) */
.nodeSizeGroup {
position: relative;
flex-shrink: 0;
width: 52px;
display: flex;
align-items: center;
justify-content: flex-end;
}
/* File size column */
.nodeSize {
font-size: 10px;
color: var(--color-text-muted, #94a3b8);
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Orphan badge */
.orphanBadge {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #f59e0b;
margin-left: 2px;
flex-shrink: 0;
}
/* Inline rename input */
.renameInput {
flex: 1;
font-size: 13px;
font-family: var(--font-family);
padding: 2px 6px;
border: 1px solid var(--color-secondary);
border-radius: 4px;
background: var(--color-bg);
color: var(--color-text);
outline: none;
box-shadow: 0 0 0 2px rgba(var(--color-secondary-rgb), 0.15);
min-width: 0;
}
/* Hover action icons -- overlay on top of file size to save width */
.nodeActionsHover {
position: absolute;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
}
.nodeRow:hover .nodeActionsHover {
opacity: 1;
}
.nodeRow:hover .nodeSize {
visibility: hidden;
}
/* Persistent action icons (scope, neutralize) -- always visible, right-aligned */
.nodeActionsPersistent {
display: flex;
align-items: center;
gap: 0;
flex-shrink: 0;
margin-left: auto;
}
.nodeActionBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--color-text-secondary, #94a3b8);
font-size: 14px;
cursor: pointer;
transition: all 0.15s ease;
padding: 0;
}
.nodeActionBtn:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--color-text, #334155);
}
.nodeActionBtnDanger:hover {
background: rgba(220, 38, 38, 0.08);
color: #dc2626;
}
/* Emoji button (scope, neutralize) -- matches SourcesTab style */
.emojiBtn {
background: none;
border: none;
cursor: pointer;
font-size: 12px;
padding: 0 2px;
line-height: 1;
flex-shrink: 0;
width: 22px;
text-align: center;
}
.emojiBtnReadonly {
cursor: default;
opacity: 0.35;
}
/* Loading */
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--color-text-secondary, #64748b);
}
.loadingSpinner {
width: 24px;
height: 24px;
border: 2px solid var(--color-border, #e2e8f0);
border-top: 2px solid var(--color-text-secondary, #64748b);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.nodeLoadingIndicator {
font-size: 10px;
color: var(--color-text-secondary, #94a3b8);
padding: 4px 0;
padding-left: 24px;
}
/* Empty state */
.emptyState {
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
}
.emptyMessage {
text-align: center;
color: var(--color-text);
opacity: 0.5;
font-size: 13px;
line-height: 1.5;
}
/* Embedded workflow / compact pickers — fixed height so flex children (treeWrapper) get a real viewport */
.embeddedPicker {
display: flex;
flex-direction: column;
flex: none !important;
min-height: 0;
overflow: hidden;
/* height + maxHeight set inline (embedMaxHeight) */
}
.embeddedPicker .treeWrapper {
flex: 1 1 0;
min-height: 0;
max-height: none;
}
/* Compact mode */
.compactMode .sectionHeader {
padding: 6px 8px;
}
.compactMode .sectionTitle {
font-size: 11px;
}
.compactMode .treeWrapper {
border: none;
box-shadow: none;
}
.compactMode .nodeRow {
padding: 0 6px;
}
.compactMode .nodeName {
font-size: 12px;
}
/* Dark theme */
@media (prefers-color-scheme: dark) {
.sectionHeader {
background: #2d3038;
}
.sectionHeader:hover {
background: #363a42;
}
.sectionHeaderNonCollapsible:hover {
background: #2d3038;
}
.nodeRow:hover {
background: rgba(124, 109, 216, 0.08);
}
.nodeRowSelected {
background: rgba(var(--color-secondary-rgb), 0.15);
}
.expandChevron:hover {
background: rgba(255, 255, 255, 0.08);
}
.nodeActionBtn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--color-text, #e2e8f0);
}
.nodeActionBtnDanger:hover {
background: rgba(220, 38, 38, 0.15);
}
.batchToolbar {
background: #2d3038;
}
.batchButton {
background: #363a42;
border-color: rgba(255, 255, 255, 0.1);
color: var(--color-text, #e2e8f0);
}
.batchButton:hover {
background: #3e424b;
}
}
/* Responsive */
@media (max-width: 768px) {
.nodeRow {
height: 36px;
padding: 0 6px;
}
.nodeName {
font-size: 12px;
}
.nodeSize {
display: none;
}
.nodeActionBtn {
width: 24px;
height: 24px;
font-size: 13px;
}
.emojiBtn {
width: 24px;
font-size: 13px;
}
.batchToolbar {
padding: 4px 8px;
flex-wrap: wrap;
}
.batchButton {
padding: 3px 8px;
font-size: 11px;
}
.filterInput {
font-size: 14px;
padding: 6px 24px 6px 8px;
}
.sectionHeader {
padding: 8px;
}
}
/* Touch devices: always show hover actions (no hover on touch) */
@media (pointer: coarse) {
.nodeActionsHover {
opacity: 1;
}
.nodeSize {
visibility: hidden;
}
}
/* Accessibility */
.nodeActionBtn:focus-visible,
.expandChevron:focus-visible {
outline: 2px solid var(--color-secondary);
outline-offset: 1px;
}
.nodeRow:focus-visible {
box-shadow: inset 0 0 0 2px rgba(var(--color-secondary-rgb), 0.3);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,783 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, within, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider, TreeBatchAction } from '../types';
const { mockPrompt } = vi.hoisted(() => ({
mockPrompt: vi.fn(() => Promise.resolve('NeuOrdner')),
}));
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: mockPrompt,
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const _ownFolder: TreeNode = {
id: 'f1',
name: 'My Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _ownFile: TreeNode = {
id: 'file1',
name: 'doc.pdf',
type: 'file',
parentId: 'f1',
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _sharedFolder: TreeNode = {
id: 'sf1',
name: 'Shared Folder',
type: 'folder',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
const _orphanFile: TreeNode = {
id: 'of1',
name: 'orphan.txt',
type: 'file',
parentId: null,
ownership: 'shared',
scope: 'mandate',
contextOrphan: true,
};
// ---------------------------------------------------------------------------
// Mock Provider Factory
// ---------------------------------------------------------------------------
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
return {
rootKey: 'test',
loadChildren: vi.fn(async (parentId) =>
nodes.filter((n) => n.parentId === parentId),
),
canCreate: vi.fn(() => true),
canRename: vi.fn((node) => node.ownership === 'own'),
canDelete: vi.fn((node) => node.ownership === 'own'),
canMove: vi.fn(() => true),
canPatchScope: vi.fn((node) => node.ownership === 'own'),
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
createChild: vi.fn(async (parentId, name) => ({
id: 'new-1',
name,
type: 'folder',
parentId,
ownership: 'own' as const,
scope: 'personal' as const,
})),
renameNode: vi.fn(async () => {}),
deleteNodes: vi.fn(async () => {}),
moveNodes: vi.fn(async () => {}),
patchScope: vi.fn(async () => {}),
patchNeutralize: vi.fn(async () => {}),
getBatchActions: vi.fn(() => []),
};
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
describe('FormGeneratorTree', () => {
describe('Rendering', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('renders tree with title and node count', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree provider={provider} ownership="own" title="Documents" />,
);
await waitFor(() => {
expect(screen.getByText('Documents')).toBeInTheDocument();
});
expect(screen.getByText('1')).toBeInTheDocument();
});
it('shows loading spinner while loading', () => {
const provider = _createMockProvider([]);
provider.loadChildren = vi.fn(() => new Promise(() => {})); // never resolves
render(<FormGeneratorTree provider={provider} ownership="own" />);
const tree = screen.getByRole('tree');
expect(tree.querySelector('[class*="loadingSpinner"]')).toBeInTheDocument();
});
it('shows empty message when no nodes', async () => {
const provider = _createMockProvider([]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
emptyMessage="Nothing here"
/>,
);
await waitFor(() => {
expect(screen.getByText('Nothing here')).toBeInTheDocument();
});
});
it('shows default empty message when no custom message', async () => {
const provider = _createMockProvider([]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('No items')).toBeInTheDocument();
});
});
it('renders nodes with correct names', async () => {
const provider = _createMockProvider([_ownFolder, _sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
it('renders nested nodes with indentation when folder expanded', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
});
it('shows contextOrphan badge for orphan nodes', async () => {
const provider = _createMockProvider([_orphanFile]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('orphan.txt')).toBeInTheDocument();
});
expect(screen.getByTitle('Context orphan')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// New folder
// ---------------------------------------------------------------------------
describe('New folder', () => {
beforeEach(() => {
mockPrompt.mockClear();
mockPrompt.mockResolvedValue('NeuOrdner');
});
it('shows header button when titled own tree has createChild', async () => {
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Documents" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.getByTitle('Neuer Ordner')).toBeInTheDocument();
});
it('does not show new folder for shared tree', async () => {
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" title="Shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
it('calls createChild at root when nothing selected', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith(null, 'NeuOrdner');
});
});
it('calls createChild under selected folder', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" title="Docs" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await user.click(screen.getByTitle('Neuer Ordner'));
await waitFor(() => {
expect(provider.createChild).toHaveBeenCalledWith('f1', 'NeuOrdner');
});
});
it('hides button when allowCreateFolder is false', async () => {
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Docs"
allowCreateFolder={false}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByTitle('Neuer Ordner')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Selection
// ---------------------------------------------------------------------------
describe('Selection', () => {
it('click selects a node', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
expect(onSelectionChange).toHaveBeenCalledWith(
expect.objectContaining({ has: expect.any(Function) }),
);
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
});
it('ctrl+click adds to selection', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const secondNode: TreeNode = {
id: 'f2',
name: 'Other Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
};
const provider = _createMockProvider([_ownFolder, secondNode]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('treeitem', { name: /My Folder/i }));
fireEvent.click(screen.getByRole('treeitem', { name: /Other Folder/i }), {
ctrlKey: true,
});
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('f2')).toBe(true);
});
it('second click on folder with cascaded child selection keeps cascaded selection (own)', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
// Expand folder first
const expandBtn = screen.getByRole('treeitem', { name: /My Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
// Select folder (cascades to children in own mode)
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
let lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('f1')).toBe(true);
expect(lastCall.has('file1')).toBe(true);
});
it('selection in shared tree does NOT cascade to children', async () => {
const user = userEvent.setup();
const onSelectionChange = vi.fn();
const sharedChild: TreeNode = {
id: 'sc1',
name: 'child.txt',
type: 'file',
parentId: 'sf1',
ownership: 'shared',
scope: 'mandate',
};
const provider = _createMockProvider([_sharedFolder, sharedChild]);
render(
<FormGeneratorTree
provider={provider}
ownership="shared"
onSelectionChange={onSelectionChange}
/>,
);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
// Expand folder
const expandBtn = screen.getByRole('treeitem', { name: /Shared Folder/i })
.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('child.txt')).toBeInTheDocument();
});
// Click folder in shared mode
await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i }));
const lastCall = onSelectionChange.mock.calls.at(-1)![0] as Set<string>;
expect(lastCall.has('sf1')).toBe(true);
expect(lastCall.has('sc1')).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Expand/Collapse
// ---------------------------------------------------------------------------
describe('Expand/Collapse', () => {
it('clicking chevron expands folder and loads children lazily', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const expandBtn = row.querySelector('[role="button"]')!;
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
expect(provider.loadChildren).toHaveBeenCalledWith('f1', 'own');
});
it('clicking expanded folder collapses it', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder, _ownFile]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const expandBtn = row.querySelector('[role="button"]')!;
// Expand
await user.click(expandBtn);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
// Collapse
await user.click(expandBtn);
await waitFor(() => {
expect(screen.queryByText('doc.pdf')).not.toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Inline Rename
// ---------------------------------------------------------------------------
describe('Inline Rename', () => {
it('double-click on own node starts rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
await waitFor(() => {
expect(screen.getByDisplayValue('My Folder')).toBeInTheDocument();
});
});
it('enter confirms rename and calls provider.renameNode', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
const input = await screen.findByDisplayValue('My Folder');
await user.clear(input);
await user.type(input, 'Renamed{Enter}');
await waitFor(() => {
expect(provider.renameNode).toHaveBeenCalledWith('f1', 'Renamed');
});
});
it('escape cancels rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /My Folder/i }));
const input = await screen.findByDisplayValue('My Folder');
await user.type(input, '{Escape}');
await waitFor(() => {
expect(screen.queryByDisplayValue('My Folder')).not.toBeInTheDocument();
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(provider.renameNode).not.toHaveBeenCalled();
});
it('double-click on shared node does NOT start rename', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
await user.dblClick(screen.getByRole('treeitem', { name: /Shared Folder/i }));
expect(screen.queryByDisplayValue('Shared Folder')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
describe('Delete', () => {
beforeEach(() => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('delete button calls provider.deleteNodes', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /My Folder/i });
const deleteBtn = within(row).getByTitle('Loeschen');
await user.click(deleteBtn);
await waitFor(() => {
expect(provider.deleteNodes).toHaveBeenCalledWith(['f1']);
});
});
it('no delete button shown for shared nodes', async () => {
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /Shared Folder/i });
expect(within(row).queryByTitle('Loeschen')).not.toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// Scope Cycling
// ---------------------------------------------------------------------------
describe('Scope Cycling', () => {
it('clicking scope icon cycles through values', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const scopeBtn = screen.getByTitle('Scope: personal');
await user.click(scopeBtn);
await waitFor(() => {
expect(provider.patchScope).toHaveBeenCalledWith(
['f1'],
'featureInstance',
);
});
});
it('scope icon is readonly in shared tree', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const scopeBtn = screen.getByTitle('Scope: mandate');
await user.click(scopeBtn);
expect(provider.patchScope).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Neutralize Toggle
// ---------------------------------------------------------------------------
describe('Neutralize Toggle', () => {
it('clicking neutralize icon toggles value', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
await waitFor(() => {
expect(provider.patchNeutralize).toHaveBeenCalledWith(['f1'], true);
});
});
it('neutralize icon is readonly in shared tree', async () => {
const user = userEvent.setup();
const sharedNodeWithNeutralize: TreeNode = {
..._sharedFolder,
neutralize: false,
};
const provider = _createMockProvider([sharedNodeWithNeutralize]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
const neutralizeBtn = screen.getByTitle('Nicht neutralisiert');
await user.click(neutralizeBtn);
expect(provider.patchNeutralize).not.toHaveBeenCalled();
});
});
// ---------------------------------------------------------------------------
// Collapsible Section
// ---------------------------------------------------------------------------
describe('Collapsible Section', () => {
it('section collapses when clicking header', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
/>,
);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
await user.click(screen.getByText('Documents'));
await waitFor(() => {
expect(screen.queryByText('My Folder')).not.toBeInTheDocument();
});
});
it('section expands when clicking header again', async () => {
const user = userEvent.setup();
const provider = _createMockProvider([_ownFolder]);
render(
<FormGeneratorTree
provider={provider}
ownership="own"
title="Documents"
collapsible
defaultCollapsed
/>,
);
// Initially collapsed
expect(screen.queryByRole('tree')).not.toBeInTheDocument();
await user.click(screen.getByText('Documents'));
await waitFor(() => {
expect(screen.getByRole('tree')).toBeInTheDocument();
});
});
});
// ---------------------------------------------------------------------------
// Batch Actions
// ---------------------------------------------------------------------------
describe('Batch Actions', () => {
it('batch toolbar appears when items selected', async () => {
const user = userEvent.setup();
const batchAction: TreeBatchAction = {
key: 'export',
label: 'Export',
onClick: vi.fn(),
};
const provider = _createMockProvider([_ownFolder]);
provider.getBatchActions = vi.fn(() => [batchAction]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('My Folder')).toBeInTheDocument();
});
expect(screen.queryByText('Export')).not.toBeInTheDocument();
await user.click(screen.getByRole('treeitem', { name: /My Folder/i }));
await waitFor(() => {
expect(screen.getByText(/selected/i)).toBeInTheDocument();
expect(screen.getByText('Export')).toBeInTheDocument();
});
});
it('batch actions filtered by ownership', async () => {
const user = userEvent.setup();
const ownOnlyAction: TreeBatchAction = {
key: 'delete-all',
label: 'Delete All',
danger: true,
ownershipFilter: 'own',
onClick: vi.fn(),
};
const provider = _createMockProvider([_sharedFolder]);
provider.getBatchActions = vi.fn(() => [ownOnlyAction]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('Shared Folder')).toBeInTheDocument();
});
await user.click(screen.getByRole('treeitem', { name: /Shared Folder/i }));
// The action has ownershipFilter='own' but we're in 'shared' mode, so it's filtered out
await waitFor(() => {
const lastCall = provider.getBatchActions as ReturnType<typeof vi.fn>;
expect(lastCall).toHaveBeenCalled();
});
expect(screen.queryByText('Delete All')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,196 @@
// Copyright (c) 2026 Patrick Motsch
// All rights reserved.
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { FormGeneratorTree } from '../FormGeneratorTree';
import type { TreeNode, TreeNodeProvider } from '../types';
vi.mock('../../../../hooks/usePrompt', () => ({
usePrompt: () => ({
prompt: vi.fn(() => Promise.resolve('x')),
PromptDialog: () => null,
}),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const _ownFolder: TreeNode = {
id: 'f1',
name: 'Target Folder',
type: 'folder',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _ownFile: TreeNode = {
id: 'file1',
name: 'doc.pdf',
type: 'file',
parentId: null,
ownership: 'own',
scope: 'personal',
neutralize: false,
};
const _sharedFolder: TreeNode = {
id: 'sf1',
name: 'Shared Folder',
type: 'folder',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
const _sharedFile: TreeNode = {
id: 'sfile1',
name: 'shared.pdf',
type: 'file',
parentId: null,
ownership: 'shared',
scope: 'mandate',
neutralize: false,
};
// ---------------------------------------------------------------------------
// Mock Provider Factory
// ---------------------------------------------------------------------------
function _createMockProvider(nodes: TreeNode[]): TreeNodeProvider {
return {
rootKey: 'test',
loadChildren: vi.fn(async (parentId) =>
nodes.filter((n) => n.parentId === parentId),
),
canCreate: vi.fn(() => true),
canRename: vi.fn((node) => node.ownership === 'own'),
canDelete: vi.fn((node) => node.ownership === 'own'),
canMove: vi.fn(() => true),
canPatchScope: vi.fn((node) => node.ownership === 'own'),
canPatchNeutralize: vi.fn((node) => node.ownership === 'own'),
createChild: vi.fn(async (parentId, name) => ({
id: 'new-1',
name,
type: 'folder',
parentId,
ownership: 'own' as const,
scope: 'personal' as const,
})),
renameNode: vi.fn(async () => {}),
deleteNodes: vi.fn(async () => {}),
moveNodes: vi.fn(async () => {}),
patchScope: vi.fn(async () => {}),
patchNeutralize: vi.fn(async () => {}),
getBatchActions: vi.fn(() => []),
};
}
// ---------------------------------------------------------------------------
// Drag and Drop Helpers
// ---------------------------------------------------------------------------
function _createDataTransfer(data: Record<string, string> = {}): DataTransfer {
const store: Record<string, string> = { ...data };
return {
setData: vi.fn((type: string, val: string) => {
store[type] = val;
}),
getData: vi.fn((type: string) => store[type] ?? ''),
effectAllowed: 'uninitialized',
dropEffect: 'none',
clearData: vi.fn(),
items: [] as unknown as DataTransferItemList,
types: Object.keys(store),
files: [] as unknown as FileList,
setDragImage: vi.fn(),
} as unknown as DataTransfer;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('FormGeneratorTree - Drag and Drop', () => {
it('drag start sets MIME application/x-poweron-tree-items with correct payload', async () => {
const provider = _createMockProvider([_ownFile, _ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
const row = screen.getByRole('treeitem', { name: /doc\.pdf/i });
const dataTransfer = _createDataTransfer();
fireEvent.dragStart(row, { dataTransfer });
expect(dataTransfer.setData).toHaveBeenCalledWith(
'application/x-poweron-tree-items',
expect.any(String),
);
const payload = JSON.parse(
(dataTransfer.setData as ReturnType<typeof vi.fn>).mock.calls[0][1],
);
expect(payload).toEqual([
{
id: 'file1',
type: 'file',
name: 'doc.pdf',
providerKey: 'test',
},
]);
});
it('drop on folder calls provider.moveNodes', async () => {
const provider = _createMockProvider([_ownFile, _ownFolder]);
render(<FormGeneratorTree provider={provider} ownership="own" />);
await waitFor(() => {
expect(screen.getByText('doc.pdf')).toBeInTheDocument();
});
const targetRow = screen.getByRole('treeitem', { name: /Target Folder/i });
const dragPayload = JSON.stringify([
{ id: 'file1', type: 'file', name: 'doc.pdf', providerKey: 'test' },
]);
const dataTransfer = _createDataTransfer({
'application/x-poweron-tree-items': dragPayload,
});
fireEvent.dragOver(targetRow, { dataTransfer });
fireEvent.drop(targetRow, { dataTransfer });
await waitFor(() => {
expect(provider.moveNodes).toHaveBeenCalledWith(['file1'], 'f1');
});
});
it('drop in shared tree is blocked (no move call)', async () => {
const provider = _createMockProvider([_sharedFile, _sharedFolder]);
render(<FormGeneratorTree provider={provider} ownership="shared" />);
await waitFor(() => {
expect(screen.getByText('shared.pdf')).toBeInTheDocument();
});
const targetRow = screen.getByRole('treeitem', { name: /Shared Folder/i });
const dragPayload = JSON.stringify([
{ id: 'sfile1', type: 'file', name: 'shared.pdf', providerKey: 'test' },
]);
const dataTransfer = _createDataTransfer({
'application/x-poweron-tree-items': dragPayload,
});
fireEvent.dragOver(targetRow, { dataTransfer });
fireEvent.drop(targetRow, { dataTransfer });
// In shared tree, the dragOver handler returns early without calling preventDefault
// so drop won't trigger moveNodes
expect(provider.moveNodes).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,9 @@
export { FormGeneratorTree } from './FormGeneratorTree';
export type {
TreeNode,
TreeNodeProvider,
TreeBatchAction,
FormGeneratorTreeProps,
Ownership,
ScopeValue,
} from './types';

View file

@ -0,0 +1,270 @@
import { FaFolder, FaFile, FaTrash } from 'react-icons/fa';
import type { TreeNodeProvider, TreeNode, Ownership, ScopeValue, TreeBatchAction } from '../types';
import api from '../../../../api';
import { getUserDataCache } from '../../../../utils/userCache';
interface FolderData {
id: string;
name: string;
parentId?: string | null;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
}
interface FileData {
id: string;
fileName: string;
folderId?: string | null;
fileSize?: number;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
sysCreatedBy?: string;
}
function _mapFolderToNode(folder: FolderData, ownership: Ownership, allFolders: FolderData[], includeFilesInTree: boolean): TreeNode {
const hasSubfoldersInApiTree = allFolders.some((f) => (f.parentId ?? null) === folder.id);
const mayHaveLazyFileChildren = includeFilesInTree && !hasSubfoldersInApiTree;
return {
id: folder.id,
name: folder.name,
type: 'folder',
parentId: folder.parentId ?? null,
ownership,
scope: folder.scope,
neutralize: folder.neutralize,
contextOrphan: folder.contextOrphan,
icon: <FaFolder />,
hasSubfoldersInApiTree,
mayHaveLazyFileChildren,
};
}
function _mapFileToNode(file: FileData, ownership: Ownership): TreeNode {
return {
id: file.id,
name: file.fileName,
type: 'file',
parentId: file.folderId ?? null,
ownership,
scope: file.scope,
neutralize: file.neutralize,
contextOrphan: file.contextOrphan,
sizeBytes: file.fileSize,
icon: <FaFile />,
};
}
export function createFolderFileProvider(options: { includeFiles?: boolean } = {}): TreeNodeProvider {
const includeFiles = options.includeFiles !== false;
const ownerParam = (ownership: Ownership) => (ownership === 'own' ? 'me' : 'shared');
const typeMap = new Map<string, 'folder' | 'file'>();
function _trackTypes(nodes: TreeNode[]) {
for (const n of nodes) {
typeMap.set(n.id, n.type as 'folder' | 'file');
}
}
function _isFile(id: string): boolean {
return typeMap.get(id) === 'file';
}
return {
rootKey: 'files',
async loadChildren(parentId, ownership) {
const owner = ownerParam(ownership);
const nodes: TreeNode[] = [];
const foldersRes = await api.get('/api/files/folders/tree', { params: { owner } });
const allFolders: FolderData[] = foldersRes.data ?? [];
const childFolders = allFolders.filter((f) => (f.parentId ?? null) === parentId);
nodes.push(...childFolders.map((f) => _mapFolderToNode(f, ownership, allFolders, includeFiles)));
if (includeFiles) {
try {
const filters: Record<string, any> = {};
if (parentId) {
filters.folderId = parentId;
}
const paginationParam = JSON.stringify({ filters, pageSize: 500 });
const filesRes = await api.get('/api/files/list', {
params: { pagination: paginationParam },
});
const data = filesRes.data;
let rawFiles: FileData[] = [];
if (data && typeof data === 'object' && 'items' in data) {
rawFiles = Array.isArray(data.items) ? data.items : [];
} else if (Array.isArray(data)) {
rawFiles = data;
}
let matched = rawFiles.filter((f) => (f.folderId ?? null) === parentId);
if (ownership === 'shared') {
const myId = getUserDataCache()?.id;
if (myId) matched = matched.filter((f) => f.sysCreatedBy !== myId);
}
nodes.push(...matched.map((f) => _mapFileToNode(f, ownership)));
} catch {
// file list may fail for shared trees; folders still render
}
}
_trackTypes(nodes);
return nodes;
},
canCreate(_parentId: string | null) {
return true;
},
canRename(node) {
return node.ownership === 'own';
},
canDelete(node) {
return node.ownership === 'own';
},
canMove(source, target) {
if (source.ownership !== 'own') return false;
if (target && target.type !== 'folder') return false;
if (target && target.id === source.id) return false;
return true;
},
canPatchScope(node) {
return node.ownership === 'own';
},
canPatchNeutralize(node) {
return node.ownership === 'own';
},
async createChild(parentId, name) {
const res = await api.post('/api/files/folders', { name, parentId });
const node = _mapFolderToNode(res.data, 'own', [], includeFiles);
typeMap.set(node.id, 'folder');
return node;
},
async renameNode(id, newName) {
if (_isFile(id)) {
await api.put(`/api/files/${id}`, { fileName: newName });
} else {
await api.patch(`/api/files/folders/${id}`, { name: newName });
}
},
async deleteNodes(ids) {
await Promise.all(ids.map((id) => {
if (_isFile(id)) return api.delete(`/api/files/${id}`);
return api.delete(`/api/files/folders/${id}`);
}));
},
async moveNodes(ids, targetParentId) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.put(`/api/files/${id}`, { folderId: targetParentId });
return api.post(`/api/files/folders/${id}/move`, { parentId: targetParentId });
}),
);
},
async patchScope(ids, scope, cascadeChildren) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/scope`, { scope });
return api.patch(`/api/files/folders/${id}/scope`, { scope, cascadeChildren });
}),
);
},
async downloadNode(node) {
if (node.type === 'folder') return;
const res = await api.get(`/api/files/${node.id}/download`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = node.name;
a.click();
window.URL.revokeObjectURL(url);
},
async patchNeutralize(ids, neutralize) {
await Promise.all(
ids.map((id) => {
if (_isFile(id)) return api.patch(`/api/files/${id}/neutralize`, { neutralize });
return api.patch(`/api/files/folders/${id}/neutralize`, { neutralize });
}),
);
},
getBatchActions(): TreeBatchAction[] {
return [
{
key: 'delete-folders',
label: 'Ordner',
icon: <><FaFolder style={{ fontSize: 10, marginRight: 1 }} /><FaTrash /></>,
danger: true,
ownershipFilter: 'own',
typeFilter: 'folder',
async onClick(folderIds) {
await Promise.all(folderIds.map((id) => api.delete(`/api/files/folders/${id}`)));
},
},
{
key: 'delete-files',
label: 'Dateien',
icon: <FaTrash />,
danger: true,
ownershipFilter: 'own',
typeFilter: 'file',
async onClick(fileIds) {
if (fileIds.length === 1) {
await api.delete(`/api/files/${fileIds[0]}`);
} else {
await api.post('/api/files/batch-delete', { fileIds });
}
},
},
{
key: 'download',
label: 'Download',
async onClick(selectedIds) {
const folderIds = selectedIds.filter((id) => typeMap.get(id) === 'folder');
const fileIds = selectedIds.filter((id) => typeMap.get(id) !== 'folder');
if (fileIds.length === 1 && folderIds.length === 0) {
const res = await api.get(`/api/files/${fileIds[0]}/download`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
const disposition = res.headers?.['content-disposition'] ?? '';
const match = disposition.match(/filename\*?=(?:UTF-8'')?(.+)/i);
a.download = match ? decodeURIComponent(match[1]) : fileIds[0];
a.click();
window.URL.revokeObjectURL(url);
} else {
const res = await api.post(
'/api/files/batch-download',
{ fileIds, folderIds },
{ responseType: 'blob' },
);
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = 'download.zip';
a.click();
window.URL.revokeObjectURL(url);
}
},
},
];
},
};
}
export default createFolderFileProvider;

View file

@ -0,0 +1,87 @@
export type Ownership = 'own' | 'shared';
export type ScopeValue = 'personal' | 'featureInstance' | 'mandate' | 'global';
export interface TreeNode<T = any> {
id: string;
name: string;
type: string;
parentId: string | null;
ownership: Ownership;
scope?: ScopeValue;
neutralize?: boolean;
contextOrphan?: boolean;
icon?: React.ReactNode;
children?: TreeNode<T>[];
isLoading?: boolean;
sizeBytes?: number;
data?: T;
/**
* From bulk `/folders/tree` response: another folder references this folder as parent.
* When false AND no lazy-file mode, omit expand affordance immediately.
*/
hasSubfoldersInApiTree?: boolean;
/**
* Folder tree mixes in files lazily (`includeFiles` in FolderFileProvider). When true but
* no subfolders in API snapshot, expand may still reveal files keep chevron until loaded.
*/
mayHaveLazyFileChildren?: boolean;
}
export interface TreeBatchAction {
key: string;
label: string;
icon?: React.ReactNode;
danger?: boolean;
ownershipFilter?: Ownership;
typeFilter?: string;
onClick: (selectedIds: string[]) => void | Promise<void>;
}
export interface TreeNodeProvider<T = any> {
rootKey: string;
loadChildren(parentId: string | null, ownership: Ownership): Promise<TreeNode<T>[]>;
canCreate?(parentId: string | null): boolean;
canRename?(node: TreeNode<T>): boolean;
canDelete?(node: TreeNode<T>): boolean;
canMove?(source: TreeNode<T>, target: TreeNode<T> | null): boolean;
canPatchScope?(node: TreeNode<T>): boolean;
canPatchNeutralize?(node: TreeNode<T>): boolean;
createChild?(parentId: string | null, name: string): Promise<TreeNode<T>>;
renameNode?(id: string, newName: string): Promise<void>;
deleteNodes?(ids: string[]): Promise<void>;
moveNodes?(ids: string[], targetParentId: string | null): Promise<void>;
patchScope?(ids: string[], scope: ScopeValue, cascadeChildren?: boolean): Promise<void>;
patchNeutralize?(ids: string[], neutralize: boolean): Promise<void>;
downloadNode?(node: TreeNode<T>): Promise<void>;
getBatchActions?(): TreeBatchAction[];
}
export interface FormGeneratorTreeProps<T = any> {
provider: TreeNodeProvider<T>;
ownership: Ownership;
title?: string;
compact?: boolean;
collapsible?: boolean;
defaultCollapsed?: boolean;
emptyMessage?: string;
showFilter?: boolean;
onNodeClick?: (node: TreeNode<T>) => void;
onSelectionChange?: (selectedIds: Set<string>) => void;
onRefresh?: () => void;
onSendToChat?: (node: TreeNode<T>) => void;
/** When false, hides "Neuer Ordner" (e.g. map from table file permissions). Default true. */
allowCreateFolder?: boolean;
className?: string;
/** Embedded pickers (e.g. automation node config): constrain overall height so the tree scrolls inside. */
embedMaxHeight?: number;
/**
* Hides checkbox, size column, per-row emoji actions, and batch toolbar saves space in pickers.
* Drag-drop defaults off when hidden; pass `enableDragDrop` to keep moving folders inside the mini tree.
*/
hideRowActionButtons?: boolean;
/** When true, folders remain draggable despite `hideRowActionButtons`. */
enableDragDrop?: boolean;
/** Hides the titled section header (count, refresh, new folder) — for compact embedded pickers. */
hideSectionHeader?: boolean;
}

View file

@ -0,0 +1,339 @@
/* ---------------------------------------------------------------------------
GroupFolderRow file-browser-style folder rows in the data table
--------------------------------------------------------------------------- */
.groupFolderRow {
background: var(--color-surface, #eef0f2);
border-bottom: 1px solid var(--color-border, #d4d9e0);
transition: background 0.12s;
user-select: none;
}
.groupFolderRow:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 8%, var(--color-surface, #eef0f2));
}
.groupFolderRow.dragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 18%, var(--color-surface, #eef0f2));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
/* Drop zone when another GROUP is dragged onto this group */
.groupFolderRow.dragOverGroup {
background: color-mix(in srgb, #d69e2e 18%, var(--color-surface, #eef0f2));
outline: 2px dashed #d69e2e;
outline-offset: -2px;
}
/* Cursor hint while dragging a group row */
.groupFolderRow[draggable="true"] {
cursor: grab;
}
.groupFolderRow[draggable="true"]:active {
cursor: grabbing;
}
/* Visual feedback: group is being dragged leftward to pop out */
.groupFolderRow.draggingOut {
opacity: 0.5;
border-left: 3px solid #d69e2e;
}
/* Folder subtree selection (aligned with tbody .tr.selected) */
.groupFolderRow.folderRowSubtreeFull {
background: rgba(124, 109, 216, 0.08);
background: rgba(var(--color-secondary-rgb), 0.08);
}
.groupFolderRow.folderRowSubtreePartial {
background: rgba(124, 109, 216, 0.04);
background: rgba(var(--color-secondary-rgb), 0.04);
}
.folderCell {
padding: 0 !important;
width: 100%;
}
.separator {
display: inline-block;
width: 1px;
height: 18px;
background: var(--color-border, #d4d9e0);
margin: 0 4px;
flex-shrink: 0;
}
.folderInner {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 10px 5px 0;
min-height: 34px;
width: 100%;
box-sizing: border-box;
}
.indent {
display: inline-block;
flex-shrink: 0;
}
/* Expand/collapse chevron button */
.chevronBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
flex-shrink: 0;
border-radius: 3px;
width: 20px;
height: 20px;
}
.chevronBtn:hover {
background: var(--color-primary-light, rgba(74,111,165,0.12));
}
/* Pure-CSS triangle arrow */
.chevronArrow {
display: inline-block;
width: 0;
height: 0;
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 6px solid var(--color-text-secondary, #64748b);
transition: transform 0.15s;
flex-shrink: 0;
}
.chevronBtn:hover .chevronArrow {
border-left-color: var(--color-primary, #4a6fa5);
}
.chevronOpen .chevronArrow {
transform: rotate(90deg);
}
/* Folder icon (SVG via react-icons) */
.folderIcon {
font-size: 14px;
flex-shrink: 0;
line-height: 1;
margin-right: 2px;
color: var(--color-primary, #4a6fa5);
display: inline-flex;
align-items: center;
}
/* Group name text */
.groupName {
font-size: 13px;
font-weight: 500;
color: var(--color-text, #2d3748);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
max-width: 300px;
}
.unnamed {
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
font-weight: 400;
}
/* Inline name input when editing */
.nameInput {
font-size: 13px;
font-weight: 500;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
padding: 2px 8px;
outline: none;
background: var(--color-bg, #fff);
color: var(--color-text, #2d3748);
min-width: 160px;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 20%, transparent);
}
/* Item count badge */
.badge {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 15%, transparent);
color: var(--color-primary, #4a6fa5);
border-radius: 10px;
padding: 0 7px;
font-size: 11px;
font-weight: 500;
line-height: 18px;
flex-shrink: 0;
margin-left: 4px;
}
/* Drop hint text */
.dropHint {
font-size: 11px;
font-style: italic;
color: var(--color-primary, #4a6fa5);
margin-left: 4px;
animation: pulse 1s ease-in-out infinite alternate;
}
@keyframes pulse {
from { opacity: 0.6; }
to { opacity: 1.0; }
}
/* ── Bulk item action buttons (same type as per-row action buttons) ── */
.actions {
display: flex;
align-items: center;
gap: 3px;
flex-shrink: 0;
margin-right: 4px;
}
.actionBtn {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d9e0);
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
font-size: 12px;
color: var(--color-text-secondary, #64748b);
transition: background 0.1s, color 0.1s, border-color 0.1s;
line-height: 1;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
}
.actionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.actionBtn:not(:disabled):hover {
background: var(--color-surface, #eef0f2);
color: var(--color-text, #2d3748);
border-color: var(--color-primary, #4a6fa5);
}
.actionBtnDanger:not(:disabled):hover {
background: color-mix(in srgb, #e53e3e 10%, transparent);
color: #c53030;
border-color: #c53030;
}
/* ── Group management buttons (rename / add-sub / delete-group) ── */
.mgmtActions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
border-left: 1px solid var(--color-border, #d4d9e0);
padding-left: 6px;
margin-left: 2px;
}
.mgmtBtn {
background: none;
border: none;
cursor: pointer;
padding: 3px 5px;
border-radius: 3px;
font-size: 11px;
color: var(--color-text-secondary, #94a3b8);
transition: background 0.1s, color 0.1s;
display: inline-flex;
align-items: center;
height: 22px;
}
.mgmtBtn:hover {
background: var(--color-border, #d4d9e0);
color: var(--color-text, #2d3748);
}
.mgmtBtnDanger:hover {
background: color-mix(in srgb, #e53e3e 12%, transparent);
color: #c53030;
}
/* ---------------------------------------------------------------------------
Breadcrumb row
--------------------------------------------------------------------------- */
.breadcrumbRow {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 6%, var(--color-bg, #fff));
}
.breadcrumbCell {
padding: 8px 14px !important;
}
.breadcrumbInner {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.backButton {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary, #4a6fa5);
font-size: 13px;
padding: 2px 8px;
border-radius: 5px;
transition: background 0.1s;
}
.backButton:hover {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 12%, transparent);
}
.breadcrumbSep {
color: var(--color-text-secondary, #94a3b8);
}
.breadcrumbCurrent {
font-weight: 600;
color: var(--color-text, #2d3748);
}
/* ---------------------------------------------------------------------------
Ungrouped section row
--------------------------------------------------------------------------- */
.ungroupedRow {
background: var(--color-bg, #f8f9fa);
transition: background 0.12s, outline 0.12s;
}
/* Drop target: item or group dragged back to root */
.ungroupedDragOver {
background: color-mix(in srgb, var(--color-primary, #4a6fa5) 10%, var(--color-bg, #f8f9fa));
outline: 2px dashed var(--color-primary, #4a6fa5);
outline-offset: -2px;
}
.ungroupedCell {
display: flex !important;
align-items: center;
gap: 6px;
padding: 5px 14px !important;
font-size: 12px;
color: var(--color-text-secondary, #94a3b8);
font-style: italic;
border-top: 1px dashed var(--color-border, #d4d9e0);
}

View file

@ -0,0 +1,379 @@
import React, { useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { useConfirm } from '../../../hooks/useConfirm';
import styles from './GroupRow.module.css';
import fgTableCss from '../FormGeneratorTable/FormGeneratorTable.module.css';
/** Legacy folder-tree row model (client-side group tree); kept for GroupFolderRow typings. */
export interface TableGroupNode {
name: string;
itemIds: string[];
}
import { FaFolder, FaFolderOpen, FaList, FaPen, FaPlus } from 'react-icons/fa';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface GroupBulkAction {
icon?: React.ReactNode;
title?: string;
variant?: 'default' | 'danger';
onClick: () => void;
disabled?: boolean;
}
/** Horizontal shift per nesting level — keep in sync with item rows (`FormGeneratorTable`). */
export const GROUP_TREE_INDENT_STEP_PX = 20;
// ---------------------------------------------------------------------------
// GroupFolderRow
// ---------------------------------------------------------------------------
/** Folder row: optional select column, then one merged cell for folder UI (spans actions + data cols — no blank actions column). */
export interface GroupFolderTableCells {
showSelect: boolean;
/** `<td colSpan>` for folder strip = `detectedColumns.length` + (1 if table has an actions column). */
dataColumnsCount: number;
selectClassName: string;
selectTdStyle?: React.CSSProperties;
}
interface GroupFolderRowProps {
node: TableGroupNode;
depth: number;
/** Checkbox for “whole subtree”: select / clear all selectable visible items under this folder. */
subtreeSelect?: {
checked: boolean;
indeterminate: boolean;
disabled: boolean;
onToggle: () => void;
};
/** When set, use split `<td>` layout; omit single-cell colspan. */
tableCells?: GroupFolderTableCells;
/** Legacy single spanning cell — only used when `tableCells` is omitted. */
colSpan?: number;
visibleCount: number;
isExpanded: boolean;
isEditing: boolean;
/** True while an ITEM is dragged over this row (drop item into group). */
isDragOver: boolean;
/** True while a GROUP is dragged over this row (nest group inside). */
isDragOverFromGroup: boolean;
bulkActions?: GroupBulkAction[];
onToggle: () => void;
onEditCommit: (name: string) => void;
onEditCancel: () => void;
onRename: () => void;
onAddSub: () => void;
// Item drag-drop
onItemDragOver: (e: React.DragEvent) => void;
onItemDrop: (e: React.DragEvent) => void;
onItemDragLeave: () => void;
// Group drag (this row is draggable)
onGroupDragStart: (e: React.DragEvent) => void;
onGroupDragEnd: () => void;
onGroupDrag?: (e: React.DragEvent) => void;
/** True while this group is being dragged leftward to pop out one level */
isDraggingOut?: boolean;
/** Hide this row via display:none (keeps it in DOM so drag operations don't break) */
hidden?: boolean;
// Group drop (another group dropped onto this)
onGroupDragOver: (e: React.DragEvent) => void;
onGroupDrop: (e: React.DragEvent) => void;
onGroupDragLeave: () => void;
}
export function GroupFolderRow({
node,
depth,
subtreeSelect,
tableCells,
colSpan,
visibleCount,
isExpanded,
isEditing,
isDragOver,
isDragOverFromGroup,
isDraggingOut,
hidden,
bulkActions = [],
onToggle,
onEditCommit,
onEditCancel,
onRename,
onAddSub,
onItemDragOver,
onItemDrop,
onItemDragLeave,
onGroupDragStart,
onGroupDragEnd,
onGroupDrag,
onGroupDragOver,
onGroupDrop,
onGroupDragLeave,
}: GroupFolderRowProps) {
const { t } = useLanguage();
const { ConfirmDialog } = useConfirm();
const inputRef = useRef<HTMLInputElement>(null);
const subtreeCbRef = useRef<HTMLInputElement>(null);
const totalCount = node.itemIds.length;
useEffect(() => {
const el = subtreeCbRef.current;
if (!el || !subtreeSelect) return;
el.indeterminate = subtreeSelect.indeterminate;
}, [subtreeSelect?.indeterminate, subtreeSelect?.checked, subtreeSelect]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const indentPx = depth * GROUP_TREE_INDENT_STEP_PX;
const _rowClass = [
styles.groupFolderRow,
tableCells ? fgTableCss.treeRowIndented : '',
isDragOver ? styles.dragOver : '',
isDragOverFromGroup ? styles.dragOverGroup : '',
isDraggingOut ? styles.draggingOut : '',
subtreeSelect?.checked && !subtreeSelect?.disabled ? styles.folderRowSubtreeFull : '',
subtreeSelect?.indeterminate && !subtreeSelect?.checked ? styles.folderRowSubtreePartial : '',
].filter(Boolean).join(' ');
const mergedColSpan =
tableCells
? tableCells.dataColumnsCount
: (colSpan ?? 1);
const folderStripStyle =
({
'--group-indent': `${indentPx}px`,
...(tableCells
? { ['--row-tree-indent' as string]: `${depth * GROUP_TREE_INDENT_STEP_PX}px` }
: {}),
}) as React.CSSProperties;
const guardDragDecor = (
e: React.DragEvent,
relay: React.DragEventHandler | undefined,
) => {
const el = e.target as HTMLElement;
if (el.closest('input, button, textarea, label')) {
e.preventDefault();
e.stopPropagation();
return;
}
relay?.(e);
};
const folderCells = (
<>
{typeof document !== 'undefined' && ReactDOM.createPortal(<ConfirmDialog />, document.body)}
<tr
className={_rowClass}
style={{ ...folderStripStyle, display: hidden ? 'none' : undefined } as React.CSSProperties}
draggable={!isEditing}
onDragStart={(e) => guardDragDecor(e, onGroupDragStart)}
onDrag={(e) => guardDragDecor(e, onGroupDrag)}
onDragEnd={(e) => guardDragDecor(e, onGroupDragEnd)}
// item drag-over
onDragOver={(e) => {
// distinguish item vs group drag via dataTransfer type
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDragOver(e);
} else {
onItemDragOver(e);
}
}}
onDrop={(e) => {
if (e.dataTransfer.types.includes('application/porta-group')) {
onGroupDrop(e);
} else {
onItemDrop(e);
}
}}
onDragLeave={() => { onItemDragLeave(); onGroupDragLeave(); }}
onDragEnter={(e) => e.preventDefault()}
>
{tableCells?.showSelect && (
<td className={tableCells.selectClassName} style={tableCells.selectTdStyle}>
{subtreeSelect && (
<input
ref={subtreeCbRef}
type="checkbox"
checked={subtreeSelect.checked}
disabled={subtreeSelect.disabled}
onChange={(e) => { e.stopPropagation(); subtreeSelect.onToggle(); }}
onClick={(e) => e.stopPropagation()}
title={node.name ? t('Auswahl unter „{name}“', { name: node.name }) : t('Auswahl dieser Gruppe')}
aria-label={node.name ? t('Alle sichtbaren Einträge in „{name}“ auswählen', { name: node.name }) : t('Alle sichtbaren Einträge in dieser Gruppe auswählen')}
/>
)}
</td>
)}
<td colSpan={tableCells ? mergedColSpan : (colSpan ?? 1)} className={styles.folderCell}>
<div className={styles.folderInner}>
{/* Indent */}
{indentPx > 0 && <span className={styles.indent} style={{ width: indentPx }} />}
{/* Chevron */}
<button
className={`${styles.chevronBtn} ${isExpanded ? styles.chevronOpen : ''}`}
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(); }}
title={isExpanded ? t('Zuklappen') : t('Aufklappen')}
tabIndex={-1}
>
<span className={styles.chevronArrow} />
</button>
{/* Folder icon */}
<span className={styles.folderIcon}>
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
</span>
{/* Name / inline input */}
{isEditing ? (
<input
ref={inputRef}
defaultValue={node.name}
className={styles.nameInput}
placeholder={t('Gruppenname…')}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditCommit(e.currentTarget.value);
if (e.key === 'Escape') onEditCancel();
}}
onBlur={(e) => onEditCommit(e.target.value)}
/>
) : (
<span className={styles.groupName} onClick={(e) => { e.stopPropagation(); onToggle(); }}>
{node.name || <em className={styles.unnamed}>{t('(Unbenannt)')}</em>}
</span>
)}
{/* Item count badge */}
{!isEditing && (
<span className={styles.badge}>
{visibleCount < totalCount && totalCount > 0
? `${visibleCount} / ${totalCount}`
: String(totalCount)}
</span>
)}
{/* Drop hint */}
{(isDragOver || isDragOverFromGroup) && (
<span className={styles.dropHint}>
{isDragOverFromGroup ? t('Als Untergruppe ablegen') : t('Hierher ziehen')}
</span>
)}
{/* ── Bulk actions (delete all, custom batch) right after badge ── */}
{!isEditing && bulkActions.length > 0 && (
<>
<span className={styles.separator} />
<span className={styles.actions}>
{bulkActions.map((action, i) => (
<button
key={i}
type="button"
className={`${styles.actionBtn} ${action.variant === 'danger' ? styles.actionBtnDanger : ''}`}
title={action.title}
disabled={!!action.disabled}
onClick={(e) => { e.stopPropagation(); if (!action.disabled) action.onClick(); }}
>
{action.icon}
</button>
))}
</span>
</>
)}
{/* ── Group management: rename / add-subgroup ── */}
{!isEditing && (
<span className={styles.mgmtActions}>
<button type="button" onClick={(e) => { e.stopPropagation(); onRename(); }} title={t('Umbenennen')} className={styles.mgmtBtn}><FaPen /></button>
<button type="button" onClick={(e) => { e.stopPropagation(); onAddSub(); }} title={t('Untergruppe erstellen')} className={styles.mgmtBtn}><FaPlus /></button>
</span>
)}
<span style={{ flex: 1 }} />
</div>
</td>
</tr>
</>
);
return folderCells;
}
// ---------------------------------------------------------------------------
// BreadcrumbRow
// ---------------------------------------------------------------------------
interface BreadcrumbRowProps {
groupName: string;
totalItems: number;
colSpan: number;
onBack: () => void;
}
export function BreadcrumbRow({ groupName, totalItems, colSpan, onBack }: BreadcrumbRowProps) {
const { t } = useLanguage();
return (
<tr className={styles.breadcrumbRow}>
<td colSpan={colSpan} className={styles.breadcrumbCell}>
<div className={styles.breadcrumbInner}>
<button className={styles.backButton} onClick={onBack}>
{t('Alle anzeigen')}
</button>
<span className={styles.breadcrumbSep}></span>
<span className={styles.breadcrumbCurrent}>{groupName}</span>
{totalItems > 0 && (
<span style={{ color: 'var(--color-text-secondary, #94a3b8)', fontSize: '11px' }}>
({totalItems} {t('Einträge')})
</span>
)}
</div>
</td>
</tr>
);
}
// ---------------------------------------------------------------------------
// UngroupedRow — also a drop zone for removing items/groups from groups
// ---------------------------------------------------------------------------
interface UngroupedRowProps {
count: number;
colSpan: number;
isDragOver?: boolean;
onDragOver?: (e: React.DragEvent) => void;
onDrop?: (e: React.DragEvent) => void;
onDragLeave?: () => void;
}
export function UngroupedRow({ count, colSpan, isDragOver, onDragOver, onDrop, onDragLeave }: UngroupedRowProps) {
const { t } = useLanguage();
return (
<tr
className={`${styles.ungroupedRow} ${isDragOver ? styles.ungroupedDragOver : ''}`}
onDragOver={onDragOver}
onDrop={onDrop}
onDragLeave={onDragLeave}
onDragEnter={(e) => e.preventDefault()}
>
<td colSpan={colSpan} className={styles.ungroupedCell}>
<span className={styles.folderIcon}><FaList /></span>
{t('Nicht zugeordnet')}
<span className={styles.badge}>{count}</span>
{isDragOver && <span className={styles.dropHint}>{t('Aus Gruppe entfernen')}</span>}
</td>
</tr>
);
}

View file

@ -0,0 +1,286 @@
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
padding: 8px 0 12px;
border-bottom: 1px solid var(--color-border, #e2e8f0);
margin-bottom: 8px;
}
.popoverAnchor {
position: relative;
}
.groupTrigger {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.groupIcon {
display: block;
font-size: 16px;
opacity: 0.9;
}
.groupTrigger:hover {
background: var(--bg-hover, rgba(15, 23, 42, 0.04));
border-color: var(--color-primary, #64748b);
}
.groupTriggerOpen {
border-color: var(--color-primary, #4a6fa5);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary, #4a6fa5) 25%, transparent);
}
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 4200;
min-width: min(360px, calc(100vw - 24px));
padding: 14px 14px 12px;
border-radius: 12px;
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #ffffff);
color: var(--color-text, #0f172a);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12);
}
.popoverTitle {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary, #94a3b8);
margin: 0 0 6px;
}
.popoverHint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.45;
color: var(--text-muted, #64748b);
}
.levelList {
display: flex;
flex-direction: column;
gap: 8px;
}
.levelRow {
display: grid;
grid-template-columns: 1fr 118px 36px;
gap: 8px;
align-items: center;
}
.select,
.selectOrder {
padding: 8px 10px;
font-size: 13px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
box-sizing: border-box;
width: 100%;
min-width: 0;
}
.select:disabled,
.selectOrder:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.iconBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
}
.iconBtn:hover:not(:disabled) {
color: #fecaca;
background: rgba(239, 68, 68, 0.12);
}
.iconBtn:disabled {
opacity: 0.25;
cursor: not-allowed;
}
.addLevelBtn {
margin-top: 12px;
width: 100%;
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px dashed var(--color-border, #475569);
background: transparent;
color: var(--text-secondary, #94a3b8);
cursor: pointer;
}
.addLevelBtn:hover {
border-color: var(--color-primary, #4a6fa5);
color: var(--color-primary, #7dd3fc);
}
.activeSummary {
font-size: 12px;
color: var(--text-secondary, #64748b);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewBlock {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-left: auto;
}
.viewLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary, #64748b);
}
.viewSelect {
min-width: 160px;
padding: 6px 10px;
font-size: 13px;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
}
.btnGhost {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--color-border, #cbd5e1);
background: transparent;
color: var(--color-text, #334155);
cursor: pointer;
}
.btnGhost:hover {
background: var(--bg-hover, #f1f5f9);
}
.btnDangerGhost {
padding: 6px 12px;
font-size: 12px;
border-radius: 8px;
border: 1px solid #fecaca;
background: transparent;
color: #b91c1c;
cursor: pointer;
}
.btnDangerGhost:hover {
background: #fef2f2;
}
.btnPrimary {
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
border-radius: 8px;
border: none;
background: var(--color-primary, #4a6fa5);
color: #fff;
cursor: pointer;
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.modalBackdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
z-index: 4500;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal {
background: var(--color-bg, #fff);
color: var(--color-text, #0f172a);
border-radius: 12px;
padding: 20px 22px;
max-width: 420px;
width: 100%;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2);
}
.modal h3 {
margin: 0 0 8px;
font-size: 17px;
}
.modalHint {
margin: 0 0 14px;
font-size: 13px;
color: var(--text-secondary, #64748b);
line-height: 1.45;
}
.modalField {
margin-bottom: 12px;
}
.modalField label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-secondary, #64748b);
}
.modalField input {
width: 100%;
padding: 8px 10px;
font-size: 14px;
border: 1px solid var(--color-border, #cbd5e1);
border-radius: 8px;
box-sizing: border-box;
}
.modalActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}

View file

@ -0,0 +1,337 @@
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
import { FaLayerGroup, FaTrash } from 'react-icons/fa';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './TableViewsBar.module.css';
export interface TableViewOption {
id: string;
viewKey: string;
displayName: string;
}
/** One grouping level (ClickUp-style): column + band order for that level. */
export interface GroupByLevelSpec {
field: string;
direction: 'asc' | 'desc';
}
export interface TableViewsBarProps {
views: TableViewOption[];
loadingViews: boolean;
activeViewKey: string | null;
activeViewId: string | null;
groupByLevels: GroupByLevelSpec[];
onGroupByLevelsChange: (levels: GroupByLevelSpec[]) => void;
onSelectView: (viewKey: string | null) => void;
columnOptions: Array<{ key: string; label: string }>;
onCreateView: (displayName: string, viewKey: string) => void | Promise<void>;
/** When a saved view is active, overwrite its config (filters, sort, grouping, folds). Optional. */
onSaveActiveView?: () => void | Promise<void>;
onUpdateViewGrouping: (viewId: string, levels: GroupByLevelSpec[]) => void | Promise<void>;
onDeleteView?: (viewId: string) => void | Promise<void>;
onReloadViews: () => void;
}
function slugify(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-_]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '') || 'view';
}
export function groupLevelsToApiPayload(levels: GroupByLevelSpec[]) {
return levels
.filter((l) => l.field)
.map((l) => ({ field: l.field, nullLabel: '—', direction: l.direction }));
}
function commitLevels(
next: GroupByLevelSpec[],
activeViewId: string | null,
onGroupByLevelsChange: (l: GroupByLevelSpec[]) => void,
onUpdateViewGrouping: (id: string, l: GroupByLevelSpec[]) => void | Promise<void>,
) {
onGroupByLevelsChange(next);
if (activeViewId) {
void Promise.resolve(onUpdateViewGrouping(activeViewId, next));
}
}
export function TableViewsBar({
views,
loadingViews,
activeViewKey,
activeViewId,
groupByLevels,
onGroupByLevelsChange,
onSelectView,
columnOptions,
onCreateView,
onSaveActiveView,
onUpdateViewGrouping,
onDeleteView,
onReloadViews,
}: TableViewsBarProps) {
const { t } = useLanguage();
const [groupMenuOpen, setGroupMenuOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
const [saveOpen, setSaveOpen] = useState(false);
const [newName, setNewName] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!groupMenuOpen) return;
const onDoc = (e: MouseEvent) => {
const el = wrapRef.current;
if (el && !el.contains(e.target as Node)) setGroupMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setGroupMenuOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [groupMenuOpen]);
const levelsForUi = useMemo(
() => (groupByLevels.length > 0 ? groupByLevels : [{ field: '', direction: 'asc' as const }]),
[groupByLevels],
);
const usedFields = useMemo(
() => new Set(groupByLevels.map((l) => l.field).filter(Boolean)),
[groupByLevels],
);
const columnsForRow = useCallback(
(_rowIdx: number, currentField: string) =>
columnOptions.filter((c) => c.key === currentField || !usedFields.has(c.key) || !c.key),
[columnOptions, usedFields],
);
const [overwriteSaving, setOverwriteSaving] = useState(false);
const _onClickSave = useCallback(async () => {
if (activeViewId && onSaveActiveView) {
setOverwriteSaving(true);
try {
await onSaveActiveView();
await onReloadViews();
} catch (e) {
console.error('Save active view failed', e);
} finally {
setOverwriteSaving(false);
}
return;
}
setSaveOpen(true);
setNewName('');
}, [activeViewId, onSaveActiveView, onReloadViews]);
const _saveNew = async () => {
const name = newName.trim();
const slug = slugify(name);
if (!name || !slug) return;
setSaving(true);
try {
await onCreateView(name, slug);
setSaveOpen(false);
setNewName('');
await onReloadViews();
} finally {
setSaving(false);
}
};
const updateLevel = (idx: number, patch: Partial<GroupByLevelSpec>) => {
const working = levelsForUi.map((l, i) => (i === idx ? { ...l, ...patch } : l));
const normalized = working.filter((l) => l.field);
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
};
const addLevelRow = () => {
commitLevels(
[...groupByLevels, { field: '', direction: 'asc' }],
activeViewId,
onGroupByLevelsChange,
onUpdateViewGrouping,
);
};
const removeLevel = (idx: number) => {
const working = levelsForUi.filter((_, i) => i !== idx);
const normalized = working.filter((l) => l.field);
commitLevels(normalized, activeViewId, onGroupByLevelsChange, onUpdateViewGrouping);
};
const summary =
groupByLevels.length === 0
? t('Keine')
: groupByLevels
.filter((l) => l.field)
.map((l) => columnOptions.find((c) => c.key === l.field)?.label ?? l.field)
.join(' ');
return (
<div className={styles.toolbar}>
<div ref={wrapRef} className={styles.popoverAnchor}>
<button
type="button"
className={`${styles.groupTrigger} ${groupMenuOpen ? styles.groupTriggerOpen : ''}`}
onClick={() => setGroupMenuOpen((o) => !o)}
aria-expanded={groupMenuOpen}
aria-label={t('Gruppieren')}
title={t('Gruppieren')}
>
<FaLayerGroup className={styles.groupIcon} aria-hidden />
</button>
{groupMenuOpen && (
<div className={styles.popover} role="dialog" aria-label={t('Gruppieren nach')}>
<div className={styles.popoverTitle}>{t('Gruppieren nach')}</div>
<p className={styles.popoverHint}>{t('Wählen Sie eine Spalte und die Reihenfolge der Gruppen.')}</p>
<div className={styles.levelList}>
{levelsForUi.map((level, idx) => (
<div key={idx} className={styles.levelRow}>
<select
className={styles.select}
aria-label={t('Spalte')}
value={level.field}
onChange={(e) => updateLevel(idx, { field: e.target.value })}
>
<option value="">{t('Spalte wählen')}</option>
{columnsForRow(idx, level.field).map((c) => (
<option key={c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</select>
<select
className={styles.selectOrder}
aria-label={t('Sortierung')}
value={level.direction}
disabled={!level.field}
onChange={(e) =>
updateLevel(idx, { direction: e.target.value === 'desc' ? 'desc' : 'asc' })
}
>
<option value="asc">{t('Aufsteigend')}</option>
<option value="desc">{t('Absteigend')}</option>
</select>
<button
type="button"
className={styles.iconBtn}
title={t('Ebene entfernen')}
aria-label={t('Ebene entfernen')}
disabled={levelsForUi.length <= 1 && !level.field}
onClick={() => removeLevel(idx)}
>
<FaTrash />
</button>
</div>
))}
</div>
<button type="button" className={styles.addLevelBtn} onClick={addLevelRow}>
{t('+ Weitere Ebene')}
</button>
</div>
)}
</div>
<span className={styles.activeSummary} title={summary}>
{groupByLevels.filter((l) => l.field).length === 0
? t('Nicht gruppiert')
: `${t('Aktiv')}: ${summary}`}
</span>
<div className={styles.viewBlock}>
<span className={styles.viewLabel}>{t('Ansicht')}</span>
<select
className={styles.viewSelect}
value={activeViewKey ?? ''}
disabled={loadingViews}
onChange={(e) => {
const v = e.target.value;
onSelectView(v === '' ? null : v);
}}
>
<option value="">{t('Standard')}</option>
{views.map((v) => (
<option key={v.id} value={v.viewKey}>
{v.displayName}
</option>
))}
</select>
<button
type="button"
className={styles.btnGhost}
disabled={loadingViews || overwriteSaving}
title={
activeViewId
? t('Aktuelle Ansicht mit Filter, Sortierung und Gruppierung überschreiben')
: t('Neue Ansicht speichern')
}
onClick={() => void _onClickSave()}
>
{overwriteSaving ? t('Wird gespeichert…') : t('Speichern…')}
</button>
{activeViewId && onDeleteView && (
<button
type="button"
className={styles.btnDangerGhost}
onClick={() => {
if (window.confirm(t('Diese Ansicht wirklich löschen?'))) {
void Promise.resolve(onDeleteView(activeViewId)).then(() => onReloadViews());
}
}}
>
{t('Löschen')}
</button>
)}
</div>
{saveOpen && (
<div
className={styles.modalBackdrop}
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setSaveOpen(false);
}}
>
<div className={styles.modal} role="dialog" aria-labelledby="new-view-title" onClick={(e) => e.stopPropagation()}>
<h3 id="new-view-title">{t('Neue Ansicht')}</h3>
<p className={styles.modalHint}>{t('Übernimmt Filter, Sortierung und Gruppierung.')}</p>
<div className={styles.modalField}>
<label htmlFor="nv-name">{t('Anzeigename')}</label>
<input
id="nv-name"
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('z. B. Nach Status')}
autoFocus
/>
</div>
<div className={styles.modalActions}>
<button type="button" className={styles.btnGhost} onClick={() => setSaveOpen(false)}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.btnPrimary}
disabled={saving || !newName.trim()}
onClick={() => void _saveNew()}
>
{saving ? t('Speichern…') : t('Erstellen')}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1 @@
export { TableViewsBar, groupLevelsToApiPayload, type TableViewsBarProps, type TableViewOption, type GroupByLevelSpec } from './TableViewsBar';

View file

@ -4,6 +4,7 @@ export * from './FormGeneratorList';
export * from './FormGeneratorForm';
export * from './FormGeneratorControls';
export * from './FormGeneratorReport';
export * from './FormGeneratorTree';
// Alias FormGeneratorTable as FormGenerator for backward compatibility
export { FormGeneratorTable as FormGenerator, FormGeneratorTableComponent as FormGeneratorComponent } from './FormGeneratorTable';

View file

@ -10,7 +10,7 @@
* - NavLink integration with React Router
*/
import React, { useState, useEffect, ReactNode } from 'react';
import React, { useState, useEffect, useRef, useCallback, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import styles from './TreeNavigation.module.css';
@ -151,6 +151,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const [isExpanded, setIsExpanded] = useState(
node.defaultExpanded ?? shouldAutoExpand ?? false
);
const containerRef = useRef<HTMLDivElement>(null);
// Auto-expand when path becomes active
useEffect(() => {
@ -159,6 +160,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
}, [currentPath, autoExpandActive, node]);
const _scrollAfterExpand = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const viewportMid = window.innerHeight / 2;
if (rect.top > viewportMid) {
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}
}, []);
// Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Differentiate: leaf active (strong highlight) vs group active (subtle text only)
@ -179,12 +190,13 @@ const TreeNode: React.FC<TreeNodeProps> = ({
}
if (isExpandable && !node.path) {
// If only expandable (no path), toggle expand
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
} else if (isExpandable && node.path) {
// If both expandable and has path, expand on click but allow navigation
if (!isExpanded) {
setIsExpanded(true);
setTimeout(_scrollAfterExpand, 50);
}
}
@ -197,7 +209,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsExpanded(!isExpanded);
const willExpand = !isExpanded;
setIsExpanded(willExpand);
if (willExpand) setTimeout(_scrollAfterExpand, 50);
};
// Render the node content (actions are rendered outside to avoid button-in-button nesting)
@ -255,7 +269,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const canRenderChildren = maxDepth === 0 || level < maxDepth;
return (
<div className={styles.treeNodeContainer}>
<div className={styles.treeNodeContainer} ref={containerRef}>
{nodeElement}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>

View file

@ -0,0 +1,394 @@
/* PeriodPicker - styled with global theme variables (Light/Dark via :root). */
.wrapper {
position: relative;
display: inline-block;
}
.trigger {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-medium, 8px);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
min-width: 280px;
justify-content: space-between;
transition: border-color 0.15s ease;
}
.trigger:hover:not(:disabled) {
border-color: var(--primary-color, #4A6FA5);
}
.trigger:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.trigger.open {
border-color: var(--primary-color, #4A6FA5);
}
.triggerIcon {
font-size: 1rem;
color: var(--text-secondary, #4A5568);
}
.triggerText {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.triggerChev {
color: var(--text-tertiary, #718096);
font-size: 0.7rem;
}
/* ---------- Popover ---------- */
.popover {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 1000;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1A202C);
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-large, 10px);
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.18);
width: 720px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 120px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover.alignRight {
left: auto;
right: 0;
}
.body {
display: grid;
grid-template-columns: 200px 240px 1fr;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
}
/* ---------- Column 1: presets ---------- */
.colPresets {
background: var(--bg-secondary, #F7FAFC);
padding: 0.625rem 0.5rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 2px;
}
.presetBtn {
text-align: left;
padding: 0.5rem 0.75rem;
background: transparent;
color: var(--text-primary, #1A202C);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.presetBtn:hover:not(:disabled) {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.presetBtn.active {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.presetBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 2: last/next N ---------- */
.colLastN {
padding: 0.875rem 1rem;
border-right: 1px solid var(--border-color, #E2E8F0);
display: flex;
flex-direction: column;
gap: 0.875rem;
}
.colTitle {
margin: 0;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary, #718096);
font-weight: 600;
}
.lastNRow {
display: flex;
gap: 0.375rem;
align-items: center;
flex-wrap: wrap;
}
.seg {
display: inline-flex;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
overflow: hidden;
}
.segBtn {
padding: 0.375rem 0.625rem;
background: var(--bg-input, #ffffff);
border: none;
cursor: pointer;
font: inherit;
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
}
.segBtn.on {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.segBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.numInput {
width: 64px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.unitSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.applyN {
margin-top: 4px;
padding: 0.375rem 0.625rem;
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
color: var(--primary-color, #4A6FA5);
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
align-self: flex-start;
}
.applyN:hover:not(:disabled) {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
}
.applyN:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ---------- Column 3: calendar ---------- */
.colCalendar {
padding: 0.75rem 0.875rem;
display: flex;
flex-direction: column;
min-width: 0;
}
.calNav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.calNavBtn {
background: transparent;
border: 1px solid transparent;
padding: 0.25rem 0.5rem;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
font-size: 0.875rem;
color: var(--text-secondary, #4A5568);
}
.calNavBtn:hover {
background: var(--bg-secondary, #F7FAFC);
color: var(--text-primary, #1A202C);
}
.calTitle {
font-size: 0.8125rem;
color: var(--text-secondary, #4A5568);
}
.calMonths {
display: flex;
flex-direction: column;
gap: 1rem;
}
.calMonth h5 {
margin: 0 0 0.375rem;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #1A202C);
}
.calGrid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
font-size: 0.75rem;
}
.dowCell {
color: var(--text-tertiary, #718096);
text-align: center;
font-size: 0.625rem;
padding: 0.25rem 0;
text-transform: uppercase;
}
.dayCell {
aspect-ratio: 1 / 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: var(--object-radius-small, 4px);
user-select: none;
color: var(--text-primary, #1A202C);
font-size: 0.75rem;
border: 1px solid transparent;
background: transparent;
font-family: inherit;
padding: 0;
}
.dayCell.muted {
color: var(--text-tertiary, #718096);
opacity: 0.55;
}
.dayCell.disabled {
color: var(--color-gray-disabled, #CBD5E0);
cursor: not-allowed;
text-decoration: line-through;
}
.dayCell:not(.disabled):hover {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
}
.dayCell.inRange {
background: var(--primary-color-light, rgba(74, 111, 165, 0.15));
border-radius: 0;
}
.dayCell.rangeStart {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.dayCell.rangeEnd {
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.dayCell.rangeStart.rangeEnd {
border-radius: var(--object-radius-small, 4px);
}
.dayCell.today {
font-weight: 700;
outline: 1px dashed var(--primary-color, #4A6FA5);
outline-offset: -2px;
}
/* ---------- Footer ---------- */
.footer {
border-top: 1px solid var(--border-color, #E2E8F0);
padding: 0.75rem 1rem;
display: flex;
gap: 0.625rem;
align-items: center;
background: var(--bg-secondary, #F7FAFC);
flex-wrap: wrap;
}
.footerLabel {
font-size: 0.75rem;
color: var(--text-secondary, #4A5568);
margin: 0 0.25rem 0 0;
}
.footerInput {
padding: 0.3125rem 0.5rem;
border: 1px solid var(--border-color, #E2E8F0);
border-radius: var(--object-radius-small, 4px);
font: inherit;
font-size: 0.8125rem;
background: var(--bg-input, #ffffff);
color: var(--text-primary, #1A202C);
}
.spacer {
flex: 1;
}
.btnGhost {
padding: 0.375rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #E2E8F0);
color: var(--text-primary, #1A202C);
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
}
.btnGhost:hover {
background: var(--bg-input, #ffffff);
}
.btnPrimary {
padding: 0.375rem 0.875rem;
background: var(--primary-color, #4A6FA5);
color: #ffffff;
border: none;
border-radius: var(--object-radius-small, 4px);
cursor: pointer;
font: inherit;
font-size: 0.8125rem;
font-weight: 600;
}
.btnPrimary:hover:not(:disabled) {
background: var(--primary-color-dark, #3D5D8A);
}
.btnPrimary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- Mobile (single calendar column) ---------- */
@media (max-width: 600px) {
.popover {
width: calc(100vw - 32px);
}
.body {
grid-template-columns: 1fr;
}
.colPresets,
.colLastN {
border-right: none;
border-bottom: 1px solid var(--border-color, #E2E8F0);
}
.colPresets {
flex-direction: row;
flex-wrap: wrap;
}
.presetBtn {
flex: 1 1 auto;
min-width: 45%;
text-align: center;
}
}

View file

@ -0,0 +1,182 @@
/**
* PeriodPicker - public component (Trigger + Popover).
*
* Carries a semantic value `{ preset, fromDate, toDate }`. Presets are
* re-resolved on every render so dynamic ranges (`ytd`, `last12Months`, )
* stay fresh when the user revisits the page.
*
* Outside-click is detected via `mousedown` (not `click`): inner elements
* are re-rendered on selection and would otherwise be detached from the DOM
* when the click event reaches the document, breaking `closest()`.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerPopover from './PeriodPickerPopover';
import {
formatIsoDateDe,
isPresetDisabled,
isValueAllowed,
resolvePeriod,
} from './PeriodPickerLogic';
import type {
PeriodPickerProps,
PeriodPreset,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
// Re-export public types so callers can import everything from one place.
export type { PeriodPickerProps, PeriodPreset, PeriodValue, PeriodDirection, PeriodPresetKind, PeriodUnit } from './PeriodPickerTypes';
export { resolvePeriod, isPresetDisabled, isValueAllowed } from './PeriodPickerLogic';
const _DEFAULT_PRESET: PeriodPreset = { kind: 'ytd' };
function _formatTriggerLabel(value: PeriodValue | null, t: (k: string) => string, placeholder: string): string {
if (!value) return placeholder;
// "Alle" intentionally skips the range suffix: the sentinel dates
// (1970-2999) would be noise in the trigger.
if (value.preset.kind === 'allTime') return t('Alle');
const range = `${formatIsoDateDe(value.fromDate)} ${formatIsoDateDe(value.toDate)}`;
switch (value.preset.kind) {
case 'ytd': return `${t('Laufendes Jahr')} · ${range}`;
case 'lastYear': return `${t('Letztes Jahr')} · ${range}`;
case 'nextYear': return `${t('Nächstes Jahr')} · ${range}`;
case 'last12Months': return `${t('Letzte 12 Monate')} · ${range}`;
case 'next12Months': return `${t('Nächste 12 Monate')} · ${range}`;
case 'thisMonth': return `${t('Dieser Monat')} · ${range}`;
case 'lastMonth': return `${t('Letzter Monat')} · ${range}`;
case 'thisQuarter': return `${t('Dieses Quartal')} · ${range}`;
case 'lastQuarter': return `${t('Letztes Quartal')} · ${range}`;
case 'lastN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Letzte')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'nextN': {
const unitLabel = _unitLabelShort(value.preset.unit, t);
return `${t('Nächste')} ${value.preset.amount} ${unitLabel} · ${range}`;
}
case 'custom':
default:
return range;
}
}
function _unitLabelShort(unit: 'day' | 'week' | 'month' | 'year', t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
export const PeriodPicker: React.FC<PeriodPickerProps> = (props) => {
const {
value,
onChange,
direction = 'any',
minDate,
maxDate,
enabledPresets,
defaultPreset = _DEFAULT_PRESET,
placeholder,
disabled = false,
className,
} = props;
const { t } = useLanguage();
const constraints = useMemo(
() => ({ direction, minDate, maxDate, enabledPresets }),
[direction, minDate, maxDate, enabledPresets],
);
// Re-resolve semantic presets on every render so values stay fresh.
const resolvedValue: PeriodValue | null = useMemo(() => {
if (!value) return null;
if (value.preset.kind === 'custom') return value;
const r = resolvePeriod(value.preset, value);
if (r.fromDate === value.fromDate && r.toDate === value.toDate) return value;
return { preset: value.preset, fromDate: r.fromDate, toDate: r.toDate };
}, [value]);
const _resolvedTrigger = useMemo(
() => _formatTriggerLabel(resolvedValue, t, placeholder || t('Zeitraum wählen')),
[resolvedValue, t, placeholder],
);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
// Outside click via mousedown (see file header).
useEffect(() => {
if (!open) return;
const _onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (wrapRef.current && wrapRef.current.contains(target)) return;
setOpen(false);
};
window.addEventListener('mousedown', _onDown);
return () => window.removeEventListener('mousedown', _onDown);
}, [open]);
const _initialDraft: PeriodValue = useMemo(() => {
if (resolvedValue) return resolvedValue;
const preset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(preset);
return { preset, fromDate: r.fromDate, toDate: r.toDate };
}, [resolvedValue, defaultPreset, constraints]);
const _handleApply = useCallback((next: PeriodValue) => {
onChange(next);
setOpen(false);
}, [onChange]);
const _handleCancel = useCallback(() => setOpen(false), []);
// If parent-passed value violates constraints, fall back silently to the
// default preset so the trigger never shows a forbidden range.
useEffect(() => {
if (resolvedValue && !isValueAllowed(resolvedValue, constraints)) {
const fallbackPreset = isPresetDisabled(defaultPreset.kind, constraints)
? ({ kind: 'custom' } as PeriodPreset)
: defaultPreset;
const r = resolvePeriod(fallbackPreset, resolvedValue);
onChange({ preset: fallbackPreset, fromDate: r.fromDate, toDate: r.toDate });
}
}, [resolvedValue, constraints, defaultPreset, onChange]);
const triggerCls = [styles.trigger];
if (open) triggerCls.push(styles.open);
return (
<div ref={wrapRef} className={`${styles.wrapper}${className ? ` ${className}` : ''}`}>
<button
type="button"
className={triggerCls.join(' ')}
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className={styles.triggerIcon} aria-hidden>📅</span>
<span className={styles.triggerText}>{_resolvedTrigger}</span>
<span className={styles.triggerChev} aria-hidden></span>
</button>
{open && (
<PeriodPickerPopover
initialValue={_initialDraft}
constraints={constraints}
onApply={_handleApply}
onCancel={_handleCancel}
/>
)}
</div>
);
};
export default PeriodPicker;

View file

@ -0,0 +1,132 @@
/**
* PeriodPicker - dual-month range calendar (vertically stacked).
*
* Pure presentation; receives a `range` and emits `onPickDate`. Constraint
* checks (min/max/direction) are delegated to `isDateDisabled`.
*/
import React, { useMemo } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
addMonthsToDate,
buildMonthCells,
isDateDisabled,
_isSameDay,
} from './PeriodPickerLogic';
import type { PeriodConstraints } from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
interface CalendarRange {
from: Date | null;
to: Date | null;
}
interface PeriodPickerCalendarProps {
anchor: Date;
onAnchorChange: (next: Date) => void;
range: CalendarRange;
onPickDate: (d: Date) => void;
constraints: PeriodConstraints;
}
function _monthLabel(d: Date, t: (k: string) => string): string {
switch (d.getMonth()) {
case 0: return `${t('Januar')} ${d.getFullYear()}`;
case 1: return `${t('Februar')} ${d.getFullYear()}`;
case 2: return `${t('März')} ${d.getFullYear()}`;
case 3: return `${t('April')} ${d.getFullYear()}`;
case 4: return `${t('Mai')} ${d.getFullYear()}`;
case 5: return `${t('Juni')} ${d.getFullYear()}`;
case 6: return `${t('Juli')} ${d.getFullYear()}`;
case 7: return `${t('August')} ${d.getFullYear()}`;
case 8: return `${t('September')} ${d.getFullYear()}`;
case 9: return `${t('Oktober')} ${d.getFullYear()}`;
case 10: return `${t('November')} ${d.getFullYear()}`;
case 11: return `${t('Dezember')} ${d.getFullYear()}`;
default: return `${d.getFullYear()}`;
}
}
function _dayOfWeekLabel(idx: number, t: (k: string) => string): string {
switch (idx) {
case 0: return t('Mo');
case 1: return t('Di');
case 2: return t('Mi');
case 3: return t('Do');
case 4: return t('Fr');
case 5: return t('Sa');
case 6: return t('So');
default: return '';
}
}
const PeriodPickerCalendar: React.FC<PeriodPickerCalendarProps> = (props) => {
const { anchor, onAnchorChange, range, onPickDate, constraints } = props;
const { t } = useLanguage();
const monthsToShow = useMemo(() => [anchor, addMonthsToDate(anchor, 1)], [anchor]);
return (
<div className={styles.colCalendar}>
<div className={styles.calNav}>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, -1))}
aria-label={t('Vorheriger Monat')}
>
</button>
<span className={styles.calTitle}>
{`${_monthLabel(monthsToShow[0], t)} ${_monthLabel(monthsToShow[1], t)}`}
</span>
<button
type="button"
className={styles.calNavBtn}
onClick={() => onAnchorChange(addMonthsToDate(anchor, 1))}
aria-label={t('Nächster Monat')}
>
</button>
</div>
<div className={styles.calMonths}>
{monthsToShow.map((monthAnchor) => (
<div key={`${monthAnchor.getFullYear()}-${monthAnchor.getMonth()}`} className={styles.calMonth}>
<h5>{_monthLabel(monthAnchor, t)}</h5>
<div className={styles.calGrid} role="grid">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<div key={`dow-${i}`} className={styles.dowCell}>{_dayOfWeekLabel(i, t)}</div>
))}
{buildMonthCells(monthAnchor).map((cell) => {
const disabled = isDateDisabled(cell.date, constraints);
const cls: string[] = [styles.dayCell];
if (!cell.inMonth) cls.push(styles.muted);
if (disabled) cls.push(styles.disabled);
if (cell.isToday) cls.push(styles.today);
if (range.from && range.to && cell.date >= range.from && cell.date <= range.to) {
cls.push(styles.inRange);
}
if (range.from && _isSameDay(cell.date, range.from)) cls.push(styles.rangeStart);
if (range.to && _isSameDay(cell.date, range.to)) cls.push(styles.rangeEnd);
return (
<button
type="button"
key={cell.iso}
className={cls.join(' ')}
disabled={disabled}
onClick={() => onPickDate(cell.date)}
>
{cell.date.getDate()}
</button>
);
})}
</div>
</div>
))}
</div>
</div>
);
};
export default PeriodPickerCalendar;

View file

@ -0,0 +1,266 @@
/**
* PeriodPicker - pure logic helpers.
*
* No React, no DOM. All date math is local-date based (no timezone shifting).
* Use ISO `YYYY-MM-DD` strings as the wire format.
*/
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
// ---------------------------------------------------------------------------
// Date primitives
// ---------------------------------------------------------------------------
const _pad = (n: number): string => String(n).padStart(2, '0');
export function toIsoDate(d: Date): string {
return `${d.getFullYear()}-${_pad(d.getMonth() + 1)}-${_pad(d.getDate())}`;
}
export function fromIsoDate(s: string | null | undefined): Date | null {
if (!s) return null;
const parts = s.split('-').map(Number);
if (parts.length !== 3 || parts.some(Number.isNaN)) return null;
return new Date(parts[0], parts[1] - 1, parts[2]);
}
export function daysInRange(fromIso: string, toIso: string): number {
const from = fromIsoDate(fromIso);
const to = fromIsoDate(toIso);
if (!from || !to) return 0;
const ms = to.getTime() - from.getTime();
return Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24)) + 1);
}
export function todayDate(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function _addDays(d: Date, n: number): Date {
const r = new Date(d);
r.setDate(r.getDate() + n);
return r;
}
function _addMonths(d: Date, n: number): Date {
const r = new Date(d);
r.setMonth(r.getMonth() + n);
return r;
}
function _addYears(d: Date, n: number): Date {
const r = new Date(d);
r.setFullYear(r.getFullYear() + n);
return r;
}
function _startOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth(), 1); }
function _endOfMonth(d: Date): Date { return new Date(d.getFullYear(), d.getMonth() + 1, 0); }
function _startOfYear(d: Date): Date { return new Date(d.getFullYear(), 0, 1); }
function _endOfYear(d: Date): Date { return new Date(d.getFullYear(), 11, 31); }
function _startOfQuarter(d: Date): Date {
return new Date(d.getFullYear(), Math.floor(d.getMonth() / 3) * 3, 1);
}
function _endOfQuarter(d: Date): Date {
const s = _startOfQuarter(d);
return new Date(s.getFullYear(), s.getMonth() + 3, 0);
}
function _shiftBy(d: Date, amount: number, unit: PeriodUnit): Date {
switch (unit) {
case 'day': return _addDays(d, amount);
case 'week': return _addDays(d, amount * 7);
case 'month': return _addMonths(d, amount);
case 'year': return _addYears(d, amount);
}
}
// ---------------------------------------------------------------------------
// Preset resolver
// ---------------------------------------------------------------------------
// Sentinel bounds used when the user picked ``Alle`` (no date filter). We keep
// the values *inside* ``PeriodValue`` so downstream code that reads
// ``fromDate``/``toDate`` doesn't break; callers that want to forward "no
// filter" to the backend should check ``preset.kind === 'allTime'`` and drop
// the dates explicitly before building the request.
export const ALL_TIME_FROM = '1970-01-01';
export const ALL_TIME_TO = '2999-12-31';
export function resolvePeriod(preset: PeriodPreset, prevValue?: PeriodValue | null): { fromDate: string; toDate: string } {
const today = todayDate();
switch (preset.kind) {
case 'allTime':
return { fromDate: ALL_TIME_FROM, toDate: ALL_TIME_TO };
case 'ytd':
return { fromDate: toIsoDate(_startOfYear(today)), toDate: toIsoDate(today) };
case 'lastYear': {
const ly = _addYears(today, -1);
return { fromDate: toIsoDate(_startOfYear(ly)), toDate: toIsoDate(_endOfYear(ly)) };
}
case 'nextYear': {
const ny = _addYears(today, 1);
return { fromDate: toIsoDate(_startOfYear(ny)), toDate: toIsoDate(_endOfYear(ny)) };
}
case 'last12Months':
return { fromDate: toIsoDate(_addMonths(today, -12)), toDate: toIsoDate(today) };
case 'next12Months':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_addMonths(today, 12)) };
case 'thisMonth':
return { fromDate: toIsoDate(_startOfMonth(today)), toDate: toIsoDate(_endOfMonth(today)) };
case 'lastMonth': {
const lm = _addMonths(today, -1);
return { fromDate: toIsoDate(_startOfMonth(lm)), toDate: toIsoDate(_endOfMonth(lm)) };
}
case 'thisQuarter':
return { fromDate: toIsoDate(_startOfQuarter(today)), toDate: toIsoDate(_endOfQuarter(today)) };
case 'lastQuarter': {
const lq = _addMonths(_startOfQuarter(today), -3);
return { fromDate: toIsoDate(_startOfQuarter(lq)), toDate: toIsoDate(_endOfQuarter(lq)) };
}
case 'lastN':
return { fromDate: toIsoDate(_shiftBy(today, -preset.amount, preset.unit)), toDate: toIsoDate(today) };
case 'nextN':
return { fromDate: toIsoDate(today), toDate: toIsoDate(_shiftBy(today, preset.amount, preset.unit)) };
case 'custom':
// Custom holds whatever was last picked; rely on the previous value if available,
// otherwise default to a single-day range at today to give the calendar an anchor.
return {
fromDate: prevValue?.fromDate || toIsoDate(today),
toDate: prevValue?.toDate || toIsoDate(today),
};
}
}
// ---------------------------------------------------------------------------
// Constraints
// ---------------------------------------------------------------------------
export function isDateDisabled(d: Date, cfg: PeriodConstraints): boolean {
const min = fromIsoDate(cfg.minDate);
const max = fromIsoDate(cfg.maxDate);
if (min && d < min) return true;
if (max && d > max) return true;
if (cfg.direction === 'past' && d > todayDate()) return true;
if (cfg.direction === 'future' && d < todayDate()) return true;
return false;
}
const _FUTURE_PRESETS: PeriodPresetKind[] = ['nextYear', 'next12Months', 'nextN'];
const _PAST_PRESETS: PeriodPresetKind[] = ['lastYear', 'last12Months', 'lastN', 'lastMonth', 'lastQuarter'];
export function isPresetDisabled(kind: PeriodPresetKind, cfg: PeriodConstraints): boolean {
if (cfg.enabledPresets && !cfg.enabledPresets.includes(kind)) return true;
if (cfg.direction === 'past' && _FUTURE_PRESETS.includes(kind)) return true;
if (cfg.direction === 'future' && _PAST_PRESETS.includes(kind)) return true;
return false;
}
export function isValueAllowed(value: PeriodValue | null, cfg: PeriodConstraints): boolean {
if (!value) return false;
if (isPresetDisabled(value.preset.kind, cfg)) return false;
if (value.preset.kind === 'custom') {
const f = fromIsoDate(value.fromDate);
const tt = fromIsoDate(value.toDate);
if (!f || !tt) return false;
if (isDateDisabled(f, cfg)) return false;
if (isDateDisabled(tt, cfg)) return false;
}
return true;
}
// Clamp an ISO date to the direction/min/max window defined by ``cfg``. Used
// for ``<input type="date">`` ``min``/``max`` attributes so the browser
// refuses invalid years instead of us silently falling back to the default
// preset afterwards.
export function clampIsoDate(_iso: string | undefined, cfg: PeriodConstraints, side: 'min' | 'max'): string | undefined {
const today = toIsoDate(todayDate());
let lo: string | undefined = cfg.minDate;
let hi: string | undefined = cfg.maxDate;
if (cfg.direction === 'past') hi = hi && hi < today ? hi : today;
if (cfg.direction === 'future') lo = lo && lo > today ? lo : today;
if (side === 'min') return lo;
return hi;
}
// ---------------------------------------------------------------------------
// Label formatting
// ---------------------------------------------------------------------------
/**
* Returns the human label for a preset kind. Caller wraps with `t()` because
* `t()` only accepts string literals (no variables).
*/
export function presetLiteralKey(kind: PeriodPresetKind): string {
switch (kind) {
case 'allTime': return 'Alle';
case 'ytd': return 'Laufendes Jahr';
case 'lastYear': return 'Letztes Jahr';
case 'nextYear': return 'Nächstes Jahr';
case 'last12Months': return 'Letzte 12 Monate';
case 'next12Months': return 'Nächste 12 Monate';
case 'thisMonth': return 'Dieser Monat';
case 'lastMonth': return 'Letzter Monat';
case 'thisQuarter': return 'Dieses Quartal';
case 'lastQuarter': return 'Letztes Quartal';
case 'lastN': return 'Letzte N';
case 'nextN': return 'Nächste N';
case 'custom': return 'Benutzerdefiniert';
}
}
export function formatIsoDateDe(iso: string): string {
const d = fromIsoDate(iso);
if (!d) return iso;
return `${_pad(d.getDate())}.${_pad(d.getMonth() + 1)}.${d.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// Calendar grid helper
// ---------------------------------------------------------------------------
export interface CalendarCell {
date: Date;
iso: string;
inMonth: boolean;
isToday: boolean;
}
/**
* Returns 6x7 = 42 cells starting on Monday for the given month anchor.
*/
export function buildMonthCells(anchor: Date): CalendarCell[] {
const start = _startOfMonth(anchor);
const leading = (start.getDay() + 6) % 7; // Monday-first
const today = todayDate();
const cells: CalendarCell[] = [];
for (let i = 0; i < 42; i++) {
const d = _addDays(start, i - leading);
cells.push({
date: d,
iso: toIsoDate(d),
inMonth: d.getMonth() === anchor.getMonth(),
isToday: _isSameDay(d, today),
});
}
return cells;
}
export function _isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function addMonthsToDate(d: Date, n: number): Date {
return _addMonths(d, n);
}
export function startOfMonth(d: Date): Date {
return _startOfMonth(d);
}

View file

@ -0,0 +1,363 @@
/**
* PeriodPicker - popover body (3 columns + footer).
*
* Receives the working `draft` value plus constraints, and delegates the
* actual commit to the parent via `onApply` / `onCancel`.
*/
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
import PeriodPickerCalendar from './PeriodPickerCalendar';
import {
clampIsoDate,
fromIsoDate,
isPresetDisabled,
presetLiteralKey,
resolvePeriod,
startOfMonth,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';
import type {
PeriodConstraints,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
} from './PeriodPickerTypes';
import styles from './PeriodPicker.module.css';
const PRESETS_ORDER: PeriodPresetKind[] = [
'allTime',
'ytd',
'lastYear',
'nextYear',
'last12Months',
'next12Months',
'thisMonth',
'lastMonth',
'thisQuarter',
'lastQuarter',
'custom',
];
function _presetLabel(kind: PeriodPresetKind, t: (k: string) => string): string {
switch (kind) {
case 'allTime': return t('Alle');
case 'ytd': return t('Laufendes Jahr');
case 'lastYear': return t('Letztes Jahr');
case 'nextYear': return t('Nächstes Jahr');
case 'last12Months': return t('Letzte 12 Monate');
case 'next12Months': return t('Nächste 12 Monate');
case 'thisMonth': return t('Dieser Monat');
case 'lastMonth': return t('Letzter Monat');
case 'thisQuarter': return t('Dieses Quartal');
case 'lastQuarter': return t('Letztes Quartal');
case 'lastN': return t('Letzte N');
case 'nextN': return t('Nächste N');
case 'custom': return t('Benutzerdefiniert');
}
// Make TS exhaustive checks happy.
return presetLiteralKey(kind);
}
function _unitLabel(unit: PeriodUnit, t: (k: string) => string): string {
switch (unit) {
case 'day': return t('Tage');
case 'week': return t('Wochen');
case 'month': return t('Monate');
case 'year': return t('Jahre');
}
}
interface PeriodPickerPopoverProps {
initialValue: PeriodValue;
constraints: PeriodConstraints;
onApply: (next: PeriodValue) => void;
onCancel: () => void;
}
interface RangePick {
from: Date | null;
to: Date | null;
}
const PeriodPickerPopover: React.FC<PeriodPickerPopoverProps> = (props) => {
const { initialValue, constraints, onApply, onCancel } = props;
const { t } = useLanguage();
const [draft, setDraft] = useState<PeriodValue>(initialValue);
const [rangePick, setRangePick] = useState<RangePick>(() => ({
from: fromIsoDate(initialValue.fromDate),
to: fromIsoDate(initialValue.toDate),
}));
const [calAnchor, setCalAnchor] = useState<Date>(() => {
const f = fromIsoDate(initialValue.fromDate) || todayDate();
return startOfMonth(f);
});
// "Letzte N / Nächste N" controls
const [lastNDirection, setLastNDirection] = useState<'last' | 'next'>(
constraints.direction === 'future' ? 'next' : 'last',
);
const [lastNAmount, setLastNAmount] = useState<number>(7);
const [lastNUnit, setLastNUnit] = useState<PeriodUnit>('day');
const _commit = useCallback((value: PeriodValue) => {
onApply(value);
}, [onApply]);
const _selectPreset = useCallback((kind: PeriodPresetKind) => {
if (isPresetDisabled(kind, constraints)) return;
if (kind === 'custom') {
// Switching from ``allTime`` back to custom: don't carry the 1970-2999
// sentinel. Seed with today/today so the user gets a sensible starting
// point and the calendar has a real anchor.
const isFromAllTime = draft.preset.kind === 'allTime';
const seedFrom = isFromAllTime ? toIsoDate(todayDate()) : draft.fromDate;
const seedTo = isFromAllTime ? toIsoDate(todayDate()) : draft.toDate;
const next: PeriodValue = {
preset: { kind: 'custom' },
fromDate: seedFrom,
toDate: seedTo,
};
setDraft(next);
setRangePick({ from: fromIsoDate(next.fromDate), to: fromIsoDate(next.toDate) });
const anchor = fromIsoDate(seedFrom);
if (anchor) setCalAnchor(startOfMonth(anchor));
return;
}
const preset: PeriodPreset = { kind } as PeriodPreset;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [constraints, draft, _commit]);
const _applyLastN = useCallback(() => {
const amount = Math.max(1, Math.floor(lastNAmount) || 1);
const preset: PeriodPreset = lastNDirection === 'last'
? { kind: 'lastN', amount, unit: lastNUnit }
: { kind: 'nextN', amount, unit: lastNUnit };
if (isPresetDisabled(preset.kind, constraints)) return;
const resolved = resolvePeriod(preset, draft);
_commit({ preset, fromDate: resolved.fromDate, toDate: resolved.toDate });
}, [lastNDirection, lastNAmount, lastNUnit, draft, constraints, _commit]);
const _onPickDate = useCallback((d: Date) => {
const { from, to } = rangePick;
let next: RangePick;
if (!from || (from && to)) {
next = { from: d, to: null };
} else if (d < from) {
next = { from: d, to: from };
} else {
next = { from, to: d };
}
setRangePick(next);
if (next.from && next.to) {
setDraft({
preset: { kind: 'custom' },
fromDate: toIsoDate(next.from),
toDate: toIsoDate(next.to),
});
}
}, [rangePick]);
const _onFooterFromChange = useCallback((iso: string) => {
// Empty string = user cleared the input; ignore so ``draft`` keeps a valid ISO.
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, fromDate: iso }));
setRangePick((prev) => ({ from: d, to: prev.to }));
// Jump the calendar to the typed month so the user immediately sees the
// selection move. Without this, the calendar stays on the current month
// and it *looks* like the input was ignored.
if (d) setCalAnchor(startOfMonth(d));
}, []);
const _onFooterToChange = useCallback((iso: string) => {
if (!iso) return;
const d = fromIsoDate(iso);
setDraft((prev) => ({ ...prev, preset: { kind: 'custom' }, toDate: iso }));
setRangePick((prev) => ({ from: prev.from, to: d }));
if (d) setCalAnchor(startOfMonth(d));
}, []);
// ``min``/``max`` on the native date inputs — prevents the user from typing
// a date that would be silently reverted by the parent's
// ``isValueAllowed`` fallback (which would replace it with ``defaultPreset``
// and lose the custom year).
const footerMin = clampIsoDate(undefined, constraints, 'min');
const footerMax = clampIsoDate(undefined, constraints, 'max');
// Keyboard: Esc cancels, Enter applies
const popRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const _onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onCancel(); }
if (e.key === 'Enter') { e.preventDefault(); onApply(draft); }
};
window.addEventListener('keydown', _onKey);
return () => window.removeEventListener('keydown', _onKey);
}, [draft, onApply, onCancel]);
useLayoutEffect(() => {
const pop = popRef.current;
if (!pop) return;
const _clamp = () => {
const parent = pop.parentElement;
if (!parent) return;
const pRect = parent.getBoundingClientRect();
const margin = 8;
const popW = pop.offsetWidth || 720;
const popH = pop.offsetHeight || 400;
let left = pRect.left;
let top = pRect.bottom + 6;
if (left + popW > window.innerWidth - margin) {
left = window.innerWidth - margin - popW;
}
if (left < margin) left = margin;
if (top + popH > window.innerHeight - margin) {
top = Math.max(margin, pRect.top - 6 - popH);
}
pop.style.position = 'fixed';
pop.style.left = `${left}px`;
pop.style.top = `${top}px`;
pop.style.right = 'auto';
pop.style.zIndex = '2001';
};
_clamp();
const id = requestAnimationFrame(() => _clamp());
return () => cancelAnimationFrame(id);
}, []);
return (
<div ref={popRef} className={styles.popover}>
<div className={styles.body}>
{/* Column 1: Presets */}
<div className={styles.colPresets}>
{PRESETS_ORDER.map((kind) => {
const disabled = isPresetDisabled(kind, constraints);
const cls = [styles.presetBtn];
if (draft.preset.kind === kind) cls.push(styles.active);
return (
<button
key={kind}
type="button"
className={cls.join(' ')}
disabled={disabled}
onClick={() => _selectPreset(kind)}
>
{_presetLabel(kind, t)}
</button>
);
})}
</div>
{/* Column 2: Letzte/Nächste N */}
<div className={styles.colLastN}>
<h4 className={styles.colTitle}>{t('Letzte oder Nächste N')}</h4>
<div className={styles.lastNRow}>
<div className={styles.seg}>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'last' ? styles.on : ''}`}
disabled={isPresetDisabled('lastN', constraints)}
onClick={() => setLastNDirection('last')}
>
{t('Letzte')}
</button>
<button
type="button"
className={`${styles.segBtn} ${lastNDirection === 'next' ? styles.on : ''}`}
disabled={isPresetDisabled('nextN', constraints)}
onClick={() => setLastNDirection('next')}
>
{t('Nächste')}
</button>
</div>
</div>
<div className={styles.lastNRow}>
<input
type="number"
min={1}
className={styles.numInput}
value={lastNAmount}
onChange={(e) => setLastNAmount(parseInt(e.target.value, 10) || 1)}
/>
<select
className={styles.unitSelect}
value={lastNUnit}
onChange={(e) => setLastNUnit(e.target.value as PeriodUnit)}
>
<option value="day">{_unitLabel('day', t)}</option>
<option value="week">{_unitLabel('week', t)}</option>
<option value="month">{_unitLabel('month', t)}</option>
<option value="year">{_unitLabel('year', t)}</option>
</select>
</div>
<button
type="button"
className={styles.applyN}
onClick={_applyLastN}
disabled={
(lastNDirection === 'last' && isPresetDisabled('lastN', constraints))
|| (lastNDirection === 'next' && isPresetDisabled('nextN', constraints))
}
>
{t('Übernehmen')}
</button>
</div>
{/* Column 3: Calendar */}
<PeriodPickerCalendar
anchor={calAnchor}
onAnchorChange={setCalAnchor}
range={rangePick}
onPickDate={_onPickDate}
constraints={constraints}
/>
</div>
{/* Footer */}
<div className={styles.footer}>
<span className={styles.footerLabel}>{t('Von')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.preset.kind === 'allTime' ? '' : draft.fromDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterFromChange(e.target.value)}
/>
<span className={styles.footerLabel}>{t('Bis')}</span>
<input
type="date"
className={styles.footerInput}
value={draft.preset.kind === 'allTime' ? '' : draft.toDate}
min={footerMin}
max={footerMax}
disabled={draft.preset.kind === 'allTime'}
onChange={(e) => _onFooterToChange(e.target.value)}
/>
<span className={styles.spacer} />
<button type="button" className={styles.btnGhost} onClick={onCancel}>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.btnPrimary}
onClick={() => onApply(draft)}
disabled={!draft.fromDate || !draft.toDate || draft.fromDate > draft.toDate}
>
{t('Übernehmen')}
</button>
</div>
</div>
);
};
export default PeriodPickerPopover;

View file

@ -0,0 +1,70 @@
/**
* PeriodPicker - shared type definitions.
*
* The component carries a *semantic* preset alongside the resolved date pair.
* Semantic presets (e.g. `ytd`, `last12Months`) are re-resolved on every render
* via `resolvePeriod` so dashboards stay fresh when revisited.
*/
export type PeriodUnit = 'day' | 'week' | 'month' | 'year';
export type PeriodPresetKind =
| 'allTime'
| 'ytd'
| 'lastYear'
| 'nextYear'
| 'last12Months'
| 'next12Months'
| 'thisMonth'
| 'lastMonth'
| 'thisQuarter'
| 'lastQuarter'
| 'lastN'
| 'nextN'
| 'custom';
export type PeriodPreset =
| { kind: 'allTime' }
| { kind: 'ytd' }
| { kind: 'lastYear' }
| { kind: 'nextYear' }
| { kind: 'last12Months' }
| { kind: 'next12Months' }
| { kind: 'thisMonth' }
| { kind: 'lastMonth' }
| { kind: 'thisQuarter' }
| { kind: 'lastQuarter' }
| { kind: 'lastN'; amount: number; unit: PeriodUnit }
| { kind: 'nextN'; amount: number; unit: PeriodUnit }
| { kind: 'custom' };
export interface PeriodValue {
preset: PeriodPreset;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
fromDate: string;
/** ISO `YYYY-MM-DD` (no time, no timezone). */
toDate: string;
}
export type PeriodDirection = 'past' | 'future' | 'any';
export interface PeriodConstraints {
direction?: PeriodDirection;
/** ISO `YYYY-MM-DD`. */
minDate?: string;
/** ISO `YYYY-MM-DD`. */
maxDate?: string;
/** Whitelist of allowed preset kinds; if omitted, all are allowed. */
enabledPresets?: PeriodPresetKind[];
}
export interface PeriodPickerProps extends PeriodConstraints {
value: PeriodValue | null;
onChange: (next: PeriodValue) => void;
/** Used as initial value if `value` is null. */
defaultPreset?: PeriodPreset;
placeholder?: string;
disabled?: boolean;
/** Optional inline className for the trigger button wrapper. */
className?: string;
}

View file

@ -0,0 +1,21 @@
export { PeriodPicker, default } from './PeriodPicker';
export type {
PeriodDirection,
PeriodPickerProps,
PeriodPreset,
PeriodPresetKind,
PeriodUnit,
PeriodValue,
PeriodConstraints,
} from './PeriodPickerTypes';
export {
daysInRange,
formatIsoDateDe,
fromIsoDate,
isPresetDisabled,
isValueAllowed,
presetLiteralKey,
resolvePeriod,
toIsoDate,
todayDate,
} from './PeriodPickerLogic';

View file

@ -10,17 +10,21 @@ export interface Tab {
export interface TabsProps {
tabs: Tab[];
defaultTabId?: string;
/** Controlled active tab. When provided, internal state is ignored. */
activeTabId?: string;
onTabChange?: (tabId: string) => void;
className?: string;
}
export function Tabs({ tabs, defaultTabId, onTabChange, className = '' }: TabsProps) {
const [activeTabId, setActiveTabId] = useState<string>(
export function Tabs({ tabs, defaultTabId, activeTabId: controlledTabId, onTabChange, className = '' }: TabsProps) {
const [internalTabId, setInternalTabId] = useState<string>(
defaultTabId || tabs[0]?.id || ''
);
const activeTabId = controlledTabId ?? internalTabId;
const handleTabClick = (tabId: string) => {
setActiveTabId(tabId);
if (!controlledTabId) setInternalTabId(tabId);
onTabChange?.(tabId);
};

View file

@ -311,3 +311,28 @@
color: #f3f4f6;
}
}
/* Touch devices: always show action buttons */
@media (pointer: coarse) {
.chatActions {
display: flex;
}
}
/* Mobile portrait */
@media (max-width: 480px) {
.chatItem {
padding: 8px 8px;
font-size: 0.9rem;
}
.actionBtn {
padding: 4px 5px;
font-size: 0.85rem;
}
.search {
font-size: 0.9rem;
padding: 8px 10px;
}
}

View file

@ -93,3 +93,12 @@
border-top-color: var(--border-dark, #374151);
}
}
/* Mobile portrait */
@media (max-width: 480px) {
.legend {
gap: 8px;
font-size: 0.7rem;
padding: 6px 8px;
}
}

View file

@ -1,71 +1,55 @@
import React, { useState, useCallback, useRef, useMemo } from 'react';
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
import type { UdbContext } from './UnifiedDataBar';
import api from '../../api';
import FolderTree from '../../components/FolderTree/FolderTree';
import type { FileNode } from '../../components/FolderTree/FolderTree';
import { useFileContext } from '../../contexts/FileContext';
import { useApiRequest } from '../../hooks/useApi';
import {
importWorkflowFromFile,
WORKFLOW_FILE_EXTENSION,
} from '../../api/workflowApi';
import { useToast } from '../../contexts/ToastContext';
import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
import styles from './FilesTab.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface FilesTabProps {
context: UdbContext;
onFileSelect?: (fileId: string, fileName?: string) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void;
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void;
/** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in
* den Graph-Editor importiert wurde. */
onWorkflowImported?: (workflowId: string) => void;
}
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
const { t } = useLanguage();
const [searchQuery, setSearchQuery] = useState('');
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
const [isDragOver, setIsDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
folders,
refreshFolders,
treeFileNodes,
treeFilesLoading,
refreshTreeFiles,
updateTreeFileNode,
expandedFolderIds,
toggleFolderExpanded,
handleCreateFolder,
handleRenameFolder,
handleDeleteFolder,
handleMoveFolder,
handleMoveFolders,
handleMoveFile,
handleMoveFiles: contextMoveFiles,
handleFileDelete,
handleDownloadFolder,
} = useFileContext();
const provider = useMemo(() => createFolderFileProvider(), []);
const [ownTreeKey, setOwnTreeKey] = useState(0);
const [sharedTreeKey, setSharedTreeKey] = useState(0);
const _folderNodes = useMemo(() => {
return folders.map(f => ({
id: f.id,
name: f.name,
parentId: f.parentId ?? null,
fileCount: f.fileCount ?? 0,
neutralize: f.neutralize ?? false,
scope: f.scope ?? 'personal',
}));
}, [folders]);
const _fileNodes: FileNode[] = useMemo(() => {
let result = treeFileNodes;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(f =>
f.fileName.toLowerCase().includes(q),
);
const _handleNodeClick = useCallback((node: TreeNode) => {
if (node.type === 'file') {
onFileSelect?.(node.id, node.name);
}
return result;
}, [treeFileNodes, searchQuery]);
}, [onFileSelect]);
const _refreshAll = useCallback(async () => {
await Promise.all([refreshTreeFiles(), refreshFolders()]);
}, [refreshTreeFiles, refreshFolders]);
const _handleRefresh = useCallback(() => {
setOwnTreeKey(k => k + 1);
setSharedTreeKey(k => k + 1);
}, []);
useEffect(() => {
const _onFileUploaded = () => _handleRefresh();
window.addEventListener('fileUploaded', _onFileUploaded);
return () => window.removeEventListener('fileUploaded', _onFileUploaded);
}, [_handleRefresh]);
const _uploadFiles = useCallback(async (fileList: FileList | File[]) => {
if (!context.instanceId || uploading) return;
@ -79,13 +63,13 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
headers: { 'Content-Type': 'multipart/form-data' },
});
}
await _refreshAll();
_handleRefresh();
} catch (err) {
console.error('File upload failed:', err);
} finally {
setUploading(false);
}
}, [context.instanceId, uploading, _refreshAll]);
}, [context.instanceId, uploading, _handleRefresh]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('Files')) {
@ -98,7 +82,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
const _handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}, []);
const _handleDrop = useCallback((e: React.DragEvent) => {
@ -117,81 +103,36 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
}
}, [_uploadFiles]);
const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => {
await handleMoveFile(fileId, targetFolderId);
}, [handleMoveFile]);
const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => {
await contextMoveFiles(fileIds, targetFolderId);
}, [contextMoveFiles]);
const _onDeleteFolder = useCallback(async (folderId: string) => {
await handleDeleteFolder(folderId);
if (selectedFolderId === folderId) setSelectedFolderId(null);
}, [handleDeleteFolder, selectedFolderId]);
const _onRenameFile = useCallback(async (fileId: string, newName: string) => {
await api.put(`/api/files/${fileId}`, { fileName: newName });
await refreshTreeFiles();
}, [refreshTreeFiles]);
const _onDeleteFile = useCallback(async (fileId: string) => {
await handleFileDelete(fileId);
}, [handleFileDelete]);
const _onDeleteFiles = useCallback(async (fileIds: string[]) => {
await api.post('/api/files/batch-delete', { fileIds });
await Promise.all([refreshTreeFiles(), refreshFolders()]);
}, [refreshTreeFiles, refreshFolders]);
const _onDeleteFolders = useCallback(async (folderIds: string[]) => {
await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true });
await Promise.all([refreshFolders(), refreshTreeFiles()]);
}, [refreshFolders, refreshTreeFiles]);
const _onScopeChange = useCallback(async (fileId: string, newScope: string) => {
updateTreeFileNode(fileId, { scope: newScope });
/* Workflow import is only available when embedded in the graph editor */
const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => {
if (context.surface !== 'graphEditor' || !context.instanceId) return;
if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return;
try {
await api.patch(`/api/files/${fileId}/scope`, { scope: newScope });
} catch (err) {
console.error('Failed to update scope:', err);
await refreshTreeFiles();
const result = await importWorkflowFromFile(request, context.instanceId, { fileId });
const warnings = result?.warnings ?? [];
const wfId = result?.workflow?.id;
if (warnings.length > 0) {
showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) }));
} else {
showSuccess(t('Workflow importiert (deaktiviert).'));
}
}, [updateTreeFileNode, refreshTreeFiles]);
if (wfId && onWorkflowImported) onWorkflowImported(wfId);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
showError(t('Import fehlgeschlagen: {msg}', { msg }));
}
}, [context.surface, context.instanceId, request, showSuccess, showError, t, onWorkflowImported]);
const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => {
updateTreeFileNode(fileId, { neutralize: newValue });
try {
await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue });
} catch (err) {
console.error('Failed to toggle neutralize:', err);
await refreshTreeFiles();
const _handleNodeClickWithImport = useCallback((node: TreeNode) => {
_handleNodeClick(node);
if (node.type === 'file') {
_handleWorkflowImport(node.id, node.name);
}
}, [updateTreeFileNode, refreshTreeFiles]);
}, [_handleNodeClick, _handleWorkflowImport]);
const _onFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => {
try {
await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to toggle folder neutralize:', err);
}
}, [refreshFolders, refreshTreeFiles]);
const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => {
try {
await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope });
await refreshFolders();
await refreshTreeFiles();
} catch (err) {
console.error('Failed to change folder scope:', err);
}
}, [refreshFolders, refreshTreeFiles]);
if (treeFilesLoading && treeFileNodes.length === 0) {
return <div className={styles.loading}>{t('Dateien laden')}</div>;
}
const _handleSendToChat = useCallback((node: TreeNode) => {
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
}, [onSendToChat]);
return (
<div
@ -201,13 +142,23 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onDrop={_handleDrop}
>
{isDragOver && (
<div style={{
<div
style={{
position: 'absolute', inset: 0,
background: 'rgba(25, 118, 210, 0.08)',
border: '2px dashed #F25843', borderRadius: 8,
zIndex: 10, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 600, color: '#F25843',
}}>
pointerEvents: 'auto',
}}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }}
onDragLeave={(e) => {
if (!e.relatedTarget || !(e.currentTarget as Node).contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}}
onDrop={_handleDrop}
>
{t('Dateien hier ablegen')}
</div>
)}
@ -224,8 +175,9 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
{uploading ? '...' : '+'}
</button>
<button
onClick={_refreshAll}
onClick={_handleRefresh}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, color: '#F25843' }}
title={t('Aktualisieren')}
>
{'\u21BB'}
</button>
@ -240,59 +192,33 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
onChange={_handleFileInputChange}
/>
<input
type="text"
placeholder={t('Dateien suchen')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4,
border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px',
}}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
<FormGeneratorTree
key={`own-${ownTreeKey}`}
provider={provider}
ownership="own"
title={t('Eigene')}
compact={true}
showFilter={true}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
<div style={{ flex: 1, overflow: 'auto' }}>
<FolderTree
folders={_folderNodes}
files={_fileNodes}
showFiles={true}
selectedFolderId={selectedFolderId}
onSelect={setSelectedFolderId}
onFileSelect={onFileSelect ? (fileId: string) => {
const file = treeFileNodes.find(f => f.id === fileId);
onFileSelect(fileId, file?.fileName);
} : undefined}
expandedIds={expandedFolderIds}
onToggleExpand={toggleFolderExpanded}
onRefresh={_refreshAll}
onCreateFolder={handleCreateFolder}
onRenameFolder={handleRenameFolder}
onDeleteFolder={_onDeleteFolder}
onMoveFolder={handleMoveFolder}
onMoveFolders={handleMoveFolders}
onMoveFile={_onMoveFile}
onMoveFiles={_onMoveFiles}
onRenameFile={_onRenameFile}
onDeleteFile={_onDeleteFile}
onDeleteFiles={_onDeleteFiles}
onDeleteFolders={_onDeleteFolders}
onDownloadFolder={handleDownloadFolder}
onScopeChange={_onScopeChange}
onNeutralizeToggle={_onNeutralizeToggle}
onFolderScopeChange={_onFolderScopeChange}
onFolderNeutralizeToggle={_onFolderNeutralizeToggle}
onSendToChat={onSendToChat}
<FormGeneratorTree
key={`shared-${sharedTreeKey}`}
provider={provider}
ownership="shared"
title={t('Geteilt mit mir')}
compact={true}
collapsible={true}
defaultCollapsed={true}
emptyMessage={t('Keine geteilten Dateien')}
onNodeClick={_handleNodeClickWithImport}
onSendToChat={_handleSendToChat}
/>
{_fileNodes.length === 0 && (
<div style={{ fontSize: 12, color: '#999', textAlign: 'center', padding: 16 }}>
{searchQuery ? t('Keine Dateien gefunden') : t('Keine Dateien. Drag & Drop zum Hochladen.')}
</div>
)}
</div>
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} {t('Persönlich')}</span>
<span>{'\uD83D\uDC64'} {t('Persoenlich')}</span>
<span>{'\uD83D\uDC65'} {t('Instanz')}</span>
<span>{'\uD83C\uDFE2'} {t('Mandant')}</span>
<span>{'\uD83D\uDD12'} {t('Neutralisiert')}</span>

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