Merge pull request #30 from valueonag/feat/unify-automation

Feat/unify automation
This commit is contained in:
Patrick Motsch 2026-04-13 09:55:04 +02:00 committed by GitHub
commit 5805c547eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
318 changed files with 52115 additions and 20577 deletions

View file

@ -0,0 +1,182 @@
# i18n — Verbleibende statische Texte
> Stand: 2026-04-08
> Diese Stellen verwenden noch hardcoded Strings in Object-Literalen, Arrays oder Hook-Defaults.
> Sie können nicht einfach mit `t()` gewrapped werden, da sie ausserhalb des React-Render-Kontexts definiert sind.
> **Lösung:** Array/Object in die Komponente verschieben oder eine Factory-Funktion `(t) => [...]` nutzen.
---
## 1. Hook-Defaults (`useConfirm`, `usePrompt`)
Diese Defaults propagieren in die gesamte App. Ein Fix hier wirkt global.
| Datei | Zeile | Property | Text |
|-------|-------|----------|------|
| `hooks/useConfirm.tsx` | 26 | `title` | `'Bestätigung'` |
| `hooks/useConfirm.tsx` | 27 | `confirmLabel` | `'Bestätigen'` |
| `hooks/useConfirm.tsx` | 28 | `cancelLabel` | `'Abbrechen'` |
| `hooks/usePrompt.tsx` | 29 | `title` | `'Eingabe'` |
| `hooks/usePrompt.tsx` | 30 | `confirmLabel` | `'OK'` |
| `hooks/usePrompt.tsx` | 31 | `cancelLabel` | `'Abbrechen'` |
---
## 2. Monatsnamen
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/billing/BillingDashboard.tsx` | 182193 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
| `components/FormGenerator/FormGeneratorReport/FormGeneratorReport.tsx` | 536541 | Monats-Select: `'Januar'` bis `'Dezember'` (12 Einträge) |
---
## 3. Tab-Labels (statische Arrays ausserhalb Komponente)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/Settings.tsx` | 2327 | `'Profil'`, `'Darstellung'`, `'Stimme & Sprache'`, `'Neutralisierung (lokal)'`, `'Datenschutz'` |
| `pages/views/workspace/WorkspaceSettingsPage.tsx` | 1617 | `'Generelle Einstellungen'`, `'Neutralisierung (Workspace)'` |
| `pages/views/neutralization/NeutralizationView.tsx` | 744745 | `'Configuration'`, `'Playground'` |
---
## 4. Spalten-Definitionen (Column-Arrays)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/admin/AdminLanguagesPage.tsx` | 2932 | `'Code'`, `'Bezeichnung'`, `'Status'`, `'Einträge'` |
| `pages/admin/AdminSubscriptionsPage.tsx` | 1323 | `'Mandant'`, `'Plan'`, `'Status'`, `'Wiederkehrend'`, `'User'`, `'Instanzen'`, `'Revenue/Mt (CHF)'`, `'Gestartet'`, `'Periodenende'`, `'Preis/User'`, `'Preis/Instanz'` |
| `pages/admin/AdminUserMandatesPage.tsx` | 104144 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 106124 | `'Bezeichnung'`, `'Beschreibung'`, `'Geltungsbereich'` |
| `pages/admin/AdminInvitationsPage.tsx` | 90155 | `'Benutzername'`, `'E-Mail'`, `'Rollen'`, `'Gültig bis'`, `'Verwendet'`, `'Erstellt'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 138155 | `'Rollen-Label'`, `'Beschreibung'`, `'Feature'` |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 205245 | `'Benutzername'`, `'E-Mail'`, `'Vollständiger Name'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 91104 | `'Name'`, `'Feature'`, `'Aktiv'` |
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 184235 | `'Workflow'`, `'Aktiv'`, `'Läuft'`, `'Steht bei'`, `'Erstellt'`, `'Zuletzt gestartet'`, `'Läufe'` |
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 174202 | `'Vorlage'`, `'Scope'`, `'Freigegeben'`, `'Erstellt von'`, `'Erstellt'` |
---
## 5. Formular-Feld-Definitionen (AttributeDefinition-Arrays)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `pages/Settings.tsx` | 5658 | `'Vollstaendiger Name'`, `'E-Mail-Adresse'`, `'Sprache'` + descriptions + placeholders |
| `pages/admin/wizards/FeatureInstanceWizard.tsx` | 7578 | `'Mandant'`, `'Feature'`, `'Bezeichnung'`, `'Aktiv'` |
| `pages/admin/InstanceDetailModal.tsx` | 186279 | `'Benutzer'`, `'Rollen'`, `'Aktiv'`, `'Einstellungen'`, `'Bezeichnung'`, `'Aktiviert'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 165171 | `'Geltungsbereich'`, `'Nur dieser Mandant'`, `'Template (wird bei neuen Mandanten kopiert)'` |
| `pages/admin/AdminMandateRolePermissionsPage.tsx` | 219221 | `'Mandanten-Rollen'`, `'Alle (inkl. Templates)'`, `'Nur Templates'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 173205 | `'Rollen-Label'`, `'Beschreibung'` + descriptions |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 272299 | `'Benutzer'`, `'Rollen'`, `'Aktiv'` |
| `pages/admin/AdminInvitationsPage.tsx` | 181 | `'Gültigkeitsdauer (Stunden)'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 629636 | `'Bezeichnung'`, `'Aktiviert'` |
---
## 6. Status-/Option-Maps (Object-Literale)
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/billing/SubscriptionTab.tsx` | 4853 | Status-Map: `'Zahlung ausstehend'`, `'Geplant'`, `'Aktiv'`, `'Testphase'`, `'Abgelaufen'` |
| `components/FlowEditor/editor/CanvasHeader.tsx` | 4042 | Status-Map: `'Entwurf'`, `'Veröffentlicht'`, `'Archiviert'` |
| `components/FlowEditor/editor/WorkflowConfigurationModal.tsx` | 1720 | Trigger-Typen: `'Manueller Trigger'`, `'Formular'`, `'Zeitplan'`, `'Immer aktiv'` |
| `components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx` | 2249 | Schedule-Optionen: `'Täglich'`, `'Werktage'`, `'Bestimmte Tage'`, `'Intervall'`, `'Sekunden'`, `'Minuten'`, `'Stunden'`, `'Tage'`, `'Jahre'` |
| `components/RbacExportImport/RbacExportImport.tsx` | 4962 | Import-Modi: `'Zusammenführen'`, `'Nur hinzufügen'`, `'Ersetzen'` + descriptions |
| `components/AccessRules/AccessRulesEditor.tsx` | 633636 | Tab-Labels: `'Daten'`, `'Ressourcen'` |
| `hooks/useAccessRules.tsx` | 2326 | Scope-Labels: `'Keine'`, `'Eigene'`, `'Gruppe'`, `'Alle'` |
---
## 7. Action-Button `title:`-Props (in Object-Literalen)
| Datei | Zeilen | Titles |
|-------|--------|--------|
| `pages/admin/AdminUsersPage.tsx` | 200212 | `'Bearbeiten'`, `'Löschen'`, `'Passwort-Link senden'` |
| `pages/admin/AdminUserMandatesPage.tsx` | 352356 | `'Rollen bearbeiten'`, `'Aus Mandant entfernen'` |
| `pages/admin/AdminMandatesPage.tsx` | 127234 | `'Mandant deaktivieren'`, `'Deaktivieren'`, `'Hard Delete (irreversibel)'`, `'Endgültig löschen'`, `'Bearbeiten'`, `'Deaktivieren (Soft-Delete)'` |
| `pages/admin/AdminMandateRolesPage.tsx` | 430435 | `'Rolle bearbeiten'`, `'Rolle löschen'` |
| `pages/admin/AdminInvitationsPage.tsx` | 354362 | `'Einladung widerrufen'`, `'Einladungs-Link anzeigen'` |
| `pages/admin/AdminFeatureRolesPage.tsx` | 372384 | `'Rolle bearbeiten'`, `'Rolle löschen'`, `'Berechtigungen verwalten'` |
| `pages/admin/AdminFeatureInstanceUsersPage.tsx` | 535539 | `'Rollen bearbeiten'`, `'Aus Instanz entfernen'` |
| `pages/admin/AdminFeatureAccessPage.tsx` | 457471 | `'Instanz löschen'`, `'Instanz bearbeiten'`, `'Rollen synchronisieren'` |
| `pages/admin/PermissionMatrix.tsx` | 39 | `'Benutzer entfernen'` |
| `pages/basedata/ConnectionsPage.tsx` | 324347 | `'Bearbeiten'`, `'Löschen'`, `'Verbinden'`, `'Token erneuern'` |
| `pages/basedata/FilesPage.tsx` | 232458 | `'Neuer Ordner'`, `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'`, `'Vorschau'` |
| `pages/basedata/PromptsPage.tsx` | 215225 | `'Duplizieren'`, `'Bearbeiten'`, `'Löschen'` |
| `pages/billing/AdminSubscriptionsPage.tsx` | 67 | `'Sofort kündigen'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 455467 | `'Bearbeiten'`, `'Löschen'`, `'In Buchhaltung synchronisieren'` |
| `pages/views/trustee/TrusteePositionDocumentsView.tsx` | 192198 | `'Verknüpfung bearbeiten'`, `'Verknüpfung entfernen'` |
| `pages/views/trustee/TrusteeDocumentsView.tsx` | 225238 | `'Bearbeiten'`, `'Löschen'`, `'Herunterladen'` |
| `pages/views/realestate/RealEstateProjectsView.tsx` | 167168 | `'Bearbeiten'`, `'Löschen'` |
| `pages/views/realestate/RealEstateParcelsView.tsx` | 186194 | `'Bearbeiten'`, `'Löschen'` |
| `pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx` | 136336 | `'Workflow umbenennen'`, `'Bearbeiten'`, `'Löschen'`, `'Umbenennen'`, `'Aktivieren'`, `'Deaktivieren'`, `'Ausführen'` |
| `pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx` | 149289 | `'Vorlage umbenennen'`, `'Im Editor öffnen'`, `'Löschen'`, `'Umbenennen'`, `'Als Workflow kopieren'`, `'Scope ändern'` |
| `pages/views/chatbot/ChatbotConversationsView.tsx` | 88 | `'Konversation löschen'` |
| `components/FlowEditor/editor/Automation2FlowEditor.tsx` | 239 | `'Workflow speichern'` |
| `components/FolderTree/FolderTree.tsx` | 384, 768 | `'Neuer Ordner'` (prompt title) |
---
## 8. Onboarding-Texte (Object-Literale)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `components/OnboardingAssistant.tsx` | 99149 | `'Mandant einrichten'`, `'Erstes Feature aktivieren'`, `'Erste Datenquelle einbinden'`, `'Ersten AI-Chat starten'` |
---
## 9. ClickUp Node Config (Feld-Optionen)
| Datei | Zeilen | Labels |
|-------|--------|--------|
| `components/FlowEditor/nodes/configs/ClickUpNodeConfig.tsx` | 786794 | `'Titel (name)'`, `'Beschreibung'`, `'Status'`, `'Priorität (14)'`, `'Fälligkeit (Datum oder ms)'`, `'Zeitschätzung (Stunden)'`, `'Zeitschätzung (ms)'`, `'Zugewiesene'`, `'Benutzerdefiniertes Feld'` |
---
## 10. Sonstige Einzel-Stellen
| Datei | Zeile | Property | Text |
|-------|-------|----------|------|
| `pages/admin/ChatbotConfigSection.tsx` | 58 | `label` | `'Althaus Preprocessor'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 167 | `label` | `'Belege'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 232 | `label` | `'Sync-Status'` |
| `pages/views/trustee/TrusteePositionsView.tsx` | 445 | `label` | `'Buchhaltung synchronisieren'` |
| `pages/views/trustee/TrusteeExpenseImportView.tsx` | 361 | `label` | `'Daily at 22:00'` |
| `pages/basedata/PromptsPage.tsx` | 81 | `label` | `'Created By'` |
| `pages/basedata/FilesPage.tsx` | 152 | `label` | `'Created By'` |
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 22 | `label` | `'Feld 1'` |
| `components/FlowEditor/nodes/start/FormStartNodeConfig.tsx` | 116 | `label` | `'Neues Feld'` |
---
## 11. Sprach-/Locale-Listen (Eigenname-Labels — evtl. NICHT übersetzen)
> Diese Listen enthalten Sprachnamen in der jeweiligen Sprache (Endonym). Sie werden typischerweise **nicht** übersetzt, da der User die Sprache in ihrer Originalbezeichnung erkennen soll.
| Datei | Zeilen | Kontext |
|-------|--------|---------|
| `pages/Settings.tsx` | 5052, 563565 | Fallback-Sprachoptionen: `'Deutsch'`, `'English'`, `'Français'` |
| `pages/views/workspace/WorkspaceInput.tsx` | 1627 | STT-Sprachliste (12 Sprachen) |
| `pages/admin/AdminLanguagesPage.tsx` | 3865 | Alle verfügbaren Sprach-Codes mit Endonymen |
| `components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx` | 2332 | Voice-Sprachliste mit Endonymen |
| `components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx` | 672675 | Fallback-Sprachoptionen |
---
## Zusammenfassung
| Kategorie | Anzahl Stellen | Dateien |
|-----------|---------------|---------|
| Hook-Defaults | 6 | 2 |
| Monatsnamen | 24 | 2 |
| Tab-Labels | 9 | 3 |
| Spalten-Definitionen | ~55 | 10 |
| Formular-Felder | ~30 | 9 |
| Status-/Option-Maps | ~30 | 7 |
| Action-Button titles | ~65 | 23 |
| Onboarding-Texte | 4 | 1 |
| ClickUp-Felder | 9 | 1 |
| Sonstige | 9 | 5 |
| Sprach-Listen (evtl. nicht übersetzen) | ~60 | 5 |
| **Total (ohne Sprach-Listen)** | **~241** | **~45 Dateien** |

File diff suppressed because it is too large Load diff

15027
scripts/i18n_missing_report.md Normal file

File diff suppressed because it is too large Load diff

17
scripts/list_t_errors.py Normal file
View file

@ -0,0 +1,17 @@
import re
from pathlib import Path
text = Path(__file__).resolve().parent.parent / "tsc-out.txt"
content = text.read_text(encoding="utf-8")
files = sorted(
{
m.group(1)
for line in content.splitlines()
if "Cannot find name 't'" in line
for m in [re.match(r"^(src/[^\(:]+\.tsx)", line)]
if m
}
)
for f in files:
print(f)
print("TOTAL", len(files), file=__import__("sys").stderr)

View file

@ -37,11 +37,13 @@ import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR'; import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store'; import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView'; import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
function App() { function App() {
// Load saved theme preference and set app name on app mount // Load saved theme preference and set app name on app mount
useEffect(() => { useEffect(() => {
@ -96,6 +98,7 @@ function App() {
{/* System-Seiten (ohne Instanz-Kontext) */} {/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="store" element={<StorePage />} /> <Route path="store" element={<StorePage />} />
<Route path="integrations" element={<IntegrationsOverviewPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} /> <Route path="gdpr" element={<GDPRPage />} />
@ -114,8 +117,14 @@ function App() {
<Route path="billing"> <Route path="billing">
<Route index element={<Navigate to="/billing/transactions" replace />} /> <Route index element={<Navigate to="/billing/transactions" replace />} />
<Route path="transactions" element={<BillingDataView />} /> <Route path="transactions" element={<BillingDataView />} />
<Route path="admin" element={<BillingAdmin />} />
</Route> </Route>
{/* ============================================== */}
{/* AUTOMATIONS DASHBOARD */}
{/* ============================================== */}
<Route path="automations" element={<AutomationsDashboardPage />} />
{/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */} {/* Legacy top-level routes redirect to dashboard (migrated to feature-instance routes) */}
<Route path="chatbot" element={<Navigate to="/" replace />} /> <Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<Navigate to="/" replace />} /> <Route path="pek" element={<Navigate to="/" replace />} />
@ -148,6 +157,8 @@ function App() {
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} /> <Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} /> <Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} /> <Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
{/* Automation Feature Views */} {/* Automation Feature Views */}
<Route path="definitions" element={<FeatureViewPage view="definitions" />} /> <Route path="definitions" element={<FeatureViewPage view="definitions" />} />
@ -191,13 +202,13 @@ function App() {
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} /> <Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} /> <Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing"> <Route path="billing">
<Route index element={<BillingAdmin />} /> <Route index element={<Navigate to="/billing/admin" replace />} />
<Route path="mandates" element={<BillingMandateView />} /> <Route path="mandates" element={<BillingMandateView />} />
</Route> </Route>
<Route path="subscriptions" element={<AdminSubscriptionsPage />} /> <Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
<Route path="logs" element={<AdminLogsPage />} /> <Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={null} />
<Route path="demo-config" element={<AdminDemoConfigPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} /> <Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} /> <Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route> </Route>

View file

@ -1,7 +1,7 @@
// api.ts // api.ts
import axios from 'axios'; import axios from 'axios';
import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils'; import { addCSRFTokenToHeaders, getCSRFToken, generateAndStoreCSRFToken } from './utils/csrfUtils';
import { clearUserDataCache } from './utils/userCache'; import { clearUserDataCache, getUserDataCache } from './utils/userCache';
// Utility function to resolve hostname to IP address // Utility function to resolve hostname to IP address
const resolveHostnameToIP = async (hostname: string): Promise<string | null> => { const resolveHostnameToIP = async (hostname: string): Promise<string | null> => {
@ -85,6 +85,13 @@ api.interceptors.request.use(
console.log('🍪 Using httpOnly cookies for authentication (automatic)'); console.log('🍪 Using httpOnly cookies for authentication (automatic)');
} }
// Send app language to backend so i18n labels match the UI
const userData = getUserDataCache();
const appLanguage = userData?.language || navigator.language.split('-')[0] || 'de';
if (config.headers) {
config.headers['Accept-Language'] = appLanguage;
}
// Add multi-tenant context headers from URL (if not already set) // Add multi-tenant context headers from URL (if not already set)
// This ensures Feature-Instance roles are loaded for permission checks // This ensures Feature-Instance roles are loaded for permission checks
const context = getContextFromUrl(); const context = getContextFromUrl();

View file

@ -18,7 +18,7 @@ export interface AttributeDefinition {
description?: string; description?: string;
required?: boolean; required?: boolean;
default?: any; default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string; options?: Array<{ value: string | number; label: string }> | string;
validation?: any; validation?: any;
ui?: any; ui?: any;
readonly?: boolean; readonly?: boolean;

View file

@ -1,532 +0,0 @@
/**
* Automation2 API
* Node types and graph execution for n8n-style flows.
*/
import type { ApiRequestOptions } from '../hooks/useApi';
const LOG = '[Automation2]';
// ============================================================================
// TYPES
// ============================================================================
export interface NodeTypeParameter {
name: string;
type: string;
required?: boolean;
description?: string;
default?: unknown;
}
export interface NodeType {
id: string;
category: string;
label: string;
description: string;
parameters: NodeTypeParameter[];
inputs: number;
outputs: number;
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
outputLabels?: string[];
executor: string;
meta?: {
icon?: string;
color?: string;
method?: string;
action?: string;
};
}
export interface NodeTypeCategory {
id: string;
label: Record<string, string> | string;
}
export interface NodeTypesResponse {
nodeTypes: NodeType[];
categories: NodeTypeCategory[];
}
export interface Automation2GraphNode {
id: string;
type: string;
parameters?: Record<string, unknown>;
}
export interface Automation2Connection {
source: string;
target: string;
sourceOutput?: number;
targetInput?: number;
}
export interface Automation2Graph {
nodes: Automation2GraphNode[];
connections: Automation2Connection[];
}
export interface ExecuteGraphResponse {
success: boolean;
nodeOutputs?: Record<string, unknown>;
error?: string;
stopped?: boolean;
failedNode?: string;
paused?: boolean;
taskId?: string;
runId?: string;
nodeId?: string;
}
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
export interface WorkflowEntryPoint {
id: string;
kind: string;
category: 'on_demand' | 'always_on';
enabled: boolean;
title: Record<string, string> | string;
description?: Record<string, string>;
config: Record<string, unknown>;
}
export interface Automation2Workflow {
id: string;
label: string;
graph: Automation2Graph;
active?: boolean;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** Enriched: run count */
runCount?: number;
/** Enriched: has active (running/paused) run */
isRunning?: boolean;
/** Enriched: status of active run */
runStatus?: string;
/** Enriched: nodeId where workflow is stuck (paused) */
stuckAtNodeId?: string;
/** Enriched: human-readable label for stuck node */
stuckAtNodeLabel?: string;
/** Enriched: created timestamp (seconds) */
createdAt?: number;
/** Enriched: last run started timestamp (seconds) */
lastStartedAt?: number;
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
/**
* Fetch node types for the flow builder (backend-driven).
* GET /api/automation2/{instanceId}/node-types?language=de
*/
export async function fetchNodeTypes(
request: ApiRequestFunction,
instanceId: string,
language = 'de'
): Promise<NodeTypesResponse> {
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
const data = await request({
url: `/api/automation2/${instanceId}/node-types`,
method: 'get',
params: { language },
});
const nodeTypes = data?.nodeTypes ?? [];
const categories = data?.categories ?? [];
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
return { nodeTypes, categories };
}
/**
* Execute an automation2 graph.
* POST /api/automation2/{instanceId}/execute
*/
export interface ExecuteGraphOptions {
/** Use a configured start on the saved workflow */
entryPointId?: string;
/** Full run envelope (overrides entry point mapping) */
runEnvelope?: Record<string, unknown>;
/** Merged into envelope.payload */
payload?: Record<string, unknown>;
}
export async function executeGraph(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
workflowId?: string,
options?: ExecuteGraphOptions
): Promise<ExecuteGraphResponse> {
console.log(
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
{ nodes: graph.nodes, connections: graph.connections, options }
);
const start = performance.now();
try {
const data: Record<string, unknown> = { graph, workflowId };
if (options?.entryPointId) data.entryPointId = options.entryPointId;
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
const result = await request({
url: `/api/automation2/${instanceId}/execute`,
method: 'post',
data,
});
const ms = Math.round(performance.now() - start);
console.log(
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
result
);
return result;
} catch (err) {
const ms = Math.round(performance.now() - start);
console.error(
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
err
);
throw err;
}
}
// -------------------------------------------------------------------------
// Workflows CRUD
// -------------------------------------------------------------------------
export async function fetchWorkflows(
request: ApiRequestFunction,
instanceId: string,
params?: { active?: boolean }
): Promise<Automation2Workflow[]> {
const data = await request({
url: `/api/automation2/${instanceId}/workflows`,
method: 'get',
params: params?.active !== undefined ? { active: params.active } : undefined,
});
return data?.workflows ?? [];
}
export async function fetchWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<Automation2Workflow> {
return await request({
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
method: 'get',
});
}
export async function createWorkflow(
request: ApiRequestFunction,
instanceId: string,
body: { label: string; graph: Automation2Graph; invocations?: WorkflowEntryPoint[] }
): Promise<Automation2Workflow> {
return await request({
url: `/api/automation2/${instanceId}/workflows`,
method: 'post',
data: body,
});
}
export async function updateWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string,
body: {
label?: string;
graph?: Automation2Graph;
invocations?: WorkflowEntryPoint[];
active?: boolean;
}
): Promise<Automation2Workflow> {
return await request({
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
method: 'put',
data: body,
});
}
export async function deleteWorkflow(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<void> {
await request({
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
method: 'delete',
});
}
export interface Automation2Run {
id: string;
workflowId: string;
status: string;
nodeOutputs?: Record<string, unknown>;
currentNodeId?: string;
}
export async function fetchWorkflowRuns(
request: ApiRequestFunction,
instanceId: string,
workflowId: string
): Promise<Automation2Run[]> {
const data = await request({
url: `/api/automation2/${instanceId}/workflows/${workflowId}/runs`,
method: 'get',
});
return data?.runs ?? [];
}
export interface CompletedRun extends Automation2Run {
workflowLabel?: string;
sysModifiedAt?: number;
sysCreatedAt?: number;
}
export async function fetchCompletedRuns(
request: ApiRequestFunction,
instanceId: string,
limit = 20
): Promise<CompletedRun[]> {
const data = await request({
url: `/api/automation2/${instanceId}/runs/completed`,
method: 'get',
params: { limit },
});
return data?.runs ?? [];
}
// -------------------------------------------------------------------------
// Tasks
// -------------------------------------------------------------------------
export interface Automation2Task {
id: string;
runId: string;
workflowId: string;
nodeId: string;
nodeType: string;
config: Record<string, unknown>;
status: string;
result?: Record<string, unknown>;
/** Workflow label (enriched by API) */
workflowLabel?: string;
/** Unix timestamp ms (from sysCreatedAt) */
createdAt?: number;
/** Optional due date - configurable in future */
dueAt?: number;
}
export async function fetchTasks(
request: ApiRequestFunction,
instanceId: string,
params?: { workflowId?: string; status?: string }
): Promise<Automation2Task[]> {
const data = await request({
url: `/api/automation2/${instanceId}/tasks`,
method: 'get',
params,
});
return data?.tasks ?? [];
}
export async function completeTask(
request: ApiRequestFunction,
instanceId: string,
taskId: string,
result: Record<string, unknown>
): Promise<ExecuteGraphResponse> {
return await request({
url: `/api/automation2/${instanceId}/tasks/${taskId}/complete`,
method: 'post',
data: { result },
});
}
// -------------------------------------------------------------------------
// Connections and Browse (for Email/SharePoint node config)
// -------------------------------------------------------------------------
export interface UserConnection {
id: string;
authority: string;
externalUsername?: string;
externalEmail?: string;
status: string;
}
export async function fetchConnections(
request: ApiRequestFunction,
instanceId: string
): Promise<UserConnection[]> {
const data = await request({
url: `/api/automation2/${instanceId}/connections`,
method: 'get',
});
return data?.connections ?? [];
}
export interface ConnectionService {
service: string;
label: string;
icon: string;
}
export async function fetchConnectionServices(
request: ApiRequestFunction,
instanceId: string,
connectionId: string
): Promise<ConnectionService[]> {
const data = await request({
url: `/api/automation2/${instanceId}/connections/${connectionId}/services`,
method: 'get',
});
return data?.services ?? [];
}
export interface BrowseEntry {
name: string;
path: string;
isFolder: boolean;
size?: number;
mimeType?: string;
metadata?: Record<string, unknown>;
}
export async function fetchBrowse(
request: ApiRequestFunction,
instanceId: string,
connectionId: string,
service: string,
path = '/'
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
const data = await request({
url: `/api/automation2/${instanceId}/connections/${connectionId}/browse`,
method: 'get',
params: { service, path },
});
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/teams/${teamId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

View file

@ -1,385 +0,0 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Automation {
id: string;
mandateId: string;
featureInstanceId: string;
label: string;
template: string | object;
placeholders: Record<string, string>;
schedule: string;
active: boolean;
status?: string;
lastExecution?: number;
nextExecution?: number;
executionLogs?: AutomationLog[];
allowedProviders?: string[];
sysCreatedAt?: number;
_updatedAt?: number;
sysCreatedByUserName?: string;
mandateName?: string;
featureInstanceName?: string;
[key: string]: any;
}
export interface AutomationLog {
id: string;
timestamp: number;
status: string;
workflowId?: string;
messages?: string[];
}
// Multilingual text type (matches backend TextMultilingual)
export interface TextMultilingual {
en: string;
ge?: string;
fr?: string;
it?: string;
}
// AutomationTemplate from DB
export interface AutomationTemplate {
id: string;
label: TextMultilingual;
overview?: TextMultilingual;
template: string; // JSON string with {{KEY:...}} placeholders
sysCreatedAt?: number;
sysCreatedBy?: string;
sysCreatedByUserName?: string;
}
// Workflow action definition from backend
export interface WorkflowAction {
method: string;
action: string;
actionId: string;
description: string;
category?: string;
parameters: WorkflowActionParameter[];
exampleJson: {
execMethod: string;
execAction: string;
execParameters: Record<string, any>;
execResultLabel: string;
};
}
export interface WorkflowActionParameter {
name: string;
type: string;
frontendType: string;
required: boolean;
default?: any;
description: string;
frontendOptions?: string | string[];
}
export interface CreateAutomationRequest {
label: string;
template: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
mandateId?: string;
featureInstanceId?: string;
}
export interface UpdateAutomationRequest {
label?: string;
template?: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
}
export interface ExecuteAutomationResponse {
id: string;
status: string;
workflowId?: string;
[key: string]: any;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch all automations for the current mandate
* Endpoint: GET /api/automations
*/
export async function fetchAutomations(request: ApiRequestFunction): Promise<Automation[]> {
console.log('📤 fetchAutomations: Making API request to /api/automations');
try {
const data = await request({
url: '/api/automations',
method: 'get'
});
console.log('📥 fetchAutomations: API response:', data);
// Handle different response formats
let automations: Automation[] = [];
if (Array.isArray(data)) {
automations = data;
} else if (data && typeof data === 'object') {
if (Array.isArray(data.automations)) {
automations = data.automations;
} else if (Array.isArray(data.items)) {
automations = data.items;
} else if (Array.isArray(data.data)) {
automations = data.data;
}
}
console.log(`✅ fetchAutomations: Returning ${automations.length} automations`);
return automations;
} catch (error) {
console.error('❌ fetchAutomations: Error fetching automations:', error);
throw error;
}
}
/**
* Fetch a single automation by ID
* Endpoint: GET /api/automations/{automationId}
*/
export async function fetchAutomation(
request: ApiRequestFunction,
automationId: string
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'get'
});
}
/**
* Create a new automation
* Endpoint: POST /api/automations
*/
export async function createAutomationApi(
request: ApiRequestFunction,
automationData: CreateAutomationRequest
): Promise<Automation> {
return await request({
url: '/api/automations',
method: 'post',
data: automationData
});
}
/**
* Update an existing automation
* Endpoint: PUT /api/automations/{automationId}
*/
export async function updateAutomationApi(
request: ApiRequestFunction,
automationId: string,
updateData: UpdateAutomationRequest
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'put',
data: updateData
});
}
/**
* Delete an automation
* Endpoint: DELETE /api/automations/{automationId}
*/
export async function deleteAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<void> {
await request({
url: `/api/automations/${automationId}`,
method: 'delete'
});
}
/**
* Execute an automation (test mode)
* Endpoint: POST /api/automations/{automationId}/execute
*/
export async function executeAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<ExecuteAutomationResponse> {
return await request({
url: `/api/automations/${automationId}/execute`,
method: 'post'
});
}
/**
* Fetch automation attributes for dynamic form generation
* Endpoint: GET /api/attributes/AutomationDefinition
*/
export async function fetchAutomationAttributes(
request: ApiRequestFunction
): Promise<any[]> {
const data = await request({
url: '/api/attributes/AutomationDefinition',
method: 'get'
});
if (data?.attributes && Array.isArray(data.attributes)) {
return data.attributes;
}
if (Array.isArray(data)) {
return data;
}
return [];
}
// ============================================================================
// AUTOMATION TEMPLATES API
// ============================================================================
/**
* Fetch all automation templates (RBAC-filtered: own templates)
* Endpoint: GET /api/automation-templates
*/
export async function fetchAutomationTemplates(
request: ApiRequestFunction,
params?: any
): Promise<any> {
const requestParams: Record<string, string> = {};
if (params && typeof params === 'object') {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
return await request({
url: '/api/automation-templates',
method: 'get',
params: requestParams,
});
}
/**
* Fetch single automation template by ID
* Endpoint: GET /api/automation-templates/{templateId}
*/
export async function fetchAutomationTemplateById(
request: ApiRequestFunction,
templateId: string
): Promise<AutomationTemplate | null> {
try {
return await request({
url: `/api/automation-templates/${templateId}`,
method: 'get'
});
} catch (error) {
console.error('Error fetching template:', error);
return null;
}
}
/**
* Create new automation template
* Endpoint: POST /api/automation-templates
*/
export async function createAutomationTemplateApi(
request: ApiRequestFunction,
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
): Promise<AutomationTemplate> {
return await request({
url: '/api/automation-templates',
method: 'post',
data: templateData
});
}
/**
* Update automation template
* Endpoint: PUT /api/automation-templates/{templateId}
*/
export async function updateAutomationTemplateApi(
request: ApiRequestFunction,
templateId: string,
templateData: Partial<AutomationTemplate>
): Promise<AutomationTemplate> {
return await request({
url: `/api/automation-templates/${templateId}`,
method: 'put',
data: templateData
});
}
/**
* Delete automation template
* Endpoint: DELETE /api/automation-templates/{templateId}
*/
export async function deleteAutomationTemplateApi(
request: ApiRequestFunction,
templateId: string
): Promise<void> {
await request({
url: `/api/automation-templates/${templateId}`,
method: 'delete'
});
}
/**
* Fetch automation template attributes for dynamic form generation
* Endpoint: GET /api/automation-templates/attributes
*/
export async function fetchAutomationTemplateAttributes(
request: ApiRequestFunction
): Promise<any[]> {
const data = await request({
url: '/api/automation-templates/attributes',
method: 'get'
});
// Backend returns: { attributes: { model: "...", attributes: [...] } }
if (data?.attributes?.attributes && Array.isArray(data.attributes.attributes)) {
return data.attributes.attributes;
}
// Fallback: direct attributes array
if (data?.attributes && Array.isArray(data.attributes)) {
return data.attributes;
}
return Array.isArray(data) ? data : [];
}
// ============================================================================
// WORKFLOW ACTIONS API
// ============================================================================
/**
* Fetch available workflow actions (RBAC-filtered)
* Endpoint: GET /api/automations/actions
*/
export async function fetchWorkflowActions(
request: ApiRequestFunction
): Promise<WorkflowAction[]> {
const data = await request({
url: '/api/automations/actions',
method: 'get'
});
return data?.actions || [];
}

View file

@ -76,7 +76,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
features: [ features: [
{ {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: 'Treuhand',
icon: 'briefcase', icon: 'briefcase',
instances: [ instances: [
{ {
@ -101,7 +101,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
}, },
{ {
code: 'chatworkflow', code: 'chatworkflow',
label: { de: 'Workflow', en: 'Workflow' }, label: 'Workflow',
icon: 'play_circle', icon: 'play_circle',
instances: [ instances: [
{ {
@ -124,7 +124,7 @@ const MOCK_RESPONSE: FeaturesMyResponse = {
features: [ features: [
{ {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: 'Treuhand',
icon: 'briefcase', icon: 'briefcase',
instances: [ instances: [
{ {
@ -234,9 +234,9 @@ export async function fetchMyFeatures(): Promise<FeaturesMyResponse> {
export async function fetchAvailableFeatures(): Promise<MandateFeature[]> { export async function fetchAvailableFeatures(): Promise<MandateFeature[]> {
if (USE_MOCK) { if (USE_MOCK) {
return [ return [
{ code: 'trustee', label: { de: 'Treuhand', en: 'Trustee' }, icon: 'briefcase', instances: [] }, { code: 'trustee', label: 'Treuhand', icon: 'briefcase', instances: [] },
{ code: 'chatworkflow', label: { de: 'Workflow', en: 'Workflow' }, icon: 'play_circle', instances: [] }, { code: 'chatworkflow', label: 'Workflow', icon: 'play_circle', instances: [] },
{ code: 'chatbot', label: { de: 'Chatbot', en: 'Chatbot' }, icon: 'chat', instances: [] }, { code: 'chatbot', label: 'Chatbot', icon: 'chat', instances: [] },
]; ];
} }

View file

@ -208,6 +208,7 @@ export interface FolderInfo {
id: string; id: string;
name: string; name: string;
parentId: string | null; parentId: string | null;
fileCount?: number;
mandateId?: string; mandateId?: string;
featureInstanceId?: string; featureInstanceId?: string;
createdAt?: number; createdAt?: number;

View file

@ -16,7 +16,7 @@ export interface Prompt {
export interface AttributeOption { export interface AttributeOption {
value: string | number; value: string | number;
label: string | { [key: string]: string }; label: string;
} }
export interface AttributeDefinition { export interface AttributeDefinition {

View file

@ -17,9 +17,9 @@ export interface StoreFeatureInstance {
export interface StoreFeature { export interface StoreFeature {
featureCode: string; featureCode: string;
label: Record<string, string>; label: string;
icon: string; icon: string;
description: Record<string, string>; description: string;
instances: StoreFeatureInstance[]; instances: StoreFeatureInstance[];
canActivate: boolean; canActivate: boolean;
} }
@ -49,7 +49,9 @@ export interface SubscriptionInfo {
status: string | null; status: string | null;
maxDataVolumeMB: number | null; maxDataVolumeMB: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
includedModules: number;
budgetAiCHF: number | null; budgetAiCHF: number | null;
budgetAiPerUserCHF: number | null;
currentFeatureInstances: number; currentFeatureInstances: number;
trialEndsAt: string | null; trialEndsAt: string | null;
} }

View file

@ -10,8 +10,8 @@ export type BillingPeriod = 'MONTHLY' | 'YEARLY' | 'NONE';
export interface SubscriptionPlan { export interface SubscriptionPlan {
planKey: string; planKey: string;
selectableByUser: boolean; selectableByUser: boolean;
title: Record<string, string>; title: string;
description: Record<string, string>; description: string;
currency: string; currency: string;
billingPeriod: BillingPeriod; billingPeriod: BillingPeriod;
pricePerUserCHF: number; pricePerUserCHF: number;
@ -19,8 +19,10 @@ export interface SubscriptionPlan {
autoRenew: boolean; autoRenew: boolean;
maxUsers: number | null; maxUsers: number | null;
maxFeatureInstances: number | null; maxFeatureInstances: number | null;
includedModules: number;
maxDataVolumeMB?: number | null; maxDataVolumeMB?: number | null;
budgetAiCHF?: number; budgetAiCHF?: number;
budgetAiPerUserCHF?: number;
trialDays: number | null; trialDays: number | null;
successorPlanKey: string | null; successorPlanKey: string | null;
} }
@ -42,11 +44,20 @@ export interface MandateSubscription {
stripeSubscriptionId: string | null; stripeSubscriptionId: string | null;
} }
export interface SubscriptionUsage {
activeUsers: number;
activeInstances: number;
usedStorageMB: number;
maxStorageMB: number | null;
storagePercent: number | null;
}
export interface SubscriptionStatusResponse { export interface SubscriptionStatusResponse {
active: boolean; active: boolean;
subscription: MandateSubscription | null; subscription: MandateSubscription | null;
plan: SubscriptionPlan | null; plan: SubscriptionPlan | null;
scheduled: MandateSubscription | null; scheduled: MandateSubscription | null;
usage: SubscriptionUsage | null;
} }
export interface ActivatePlanResponse { export interface ActivatePlanResponse {

View file

@ -107,10 +107,10 @@ export interface TrusteePosition {
export interface AccountingConnectorInfo { export interface AccountingConnectorInfo {
connectorType: string; connectorType: string;
label: Record<string, string>; label: string;
configFields: Array<{ configFields: Array<{
key: string; key: string;
label: Record<string, string>; label: string;
fieldType: string; fieldType: string;
secret: boolean; secret: boolean;
required: boolean; required: boolean;
@ -751,6 +751,41 @@ export async function deletePositionDocument(
}); });
} }
// ============================================================================
// QUICK ACTIONS API
// ============================================================================
export interface QuickActionResponse {
actions: Array<{
id: string;
label: string;
description: string;
icon: string;
color: string;
category: string;
actionType: 'agentPrompt' | 'workflow' | 'link';
config: Record<string, any>;
sortOrder: number;
}>;
categories: Array<{
id: string;
label: string;
sortOrder: number;
}>;
}
export async function fetchQuickActions(
request: ApiRequestFunction,
instanceId: string,
language: string = 'de'
): Promise<QuickActionResponse> {
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/quick-actions`,
method: 'get',
params: { language }
});
}
// ============================================================================ // ============================================================================
// ACCOUNTING API // ACCOUNTING API
// ============================================================================ // ============================================================================
@ -838,3 +873,17 @@ export async function fetchSyncStatus(
method: 'get' method: 'get'
}); });
} }
export async function exportAccountingData(
request: ApiRequestFunction,
instanceId: string
): Promise<void> {
const url = `${_getTrusteeBaseUrl(instanceId)}/accounting/export-data`;
const response = await request({ url, method: 'get' });
const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `trustee_data_${instanceId.slice(0, 8)}.json`;
link.click();
URL.revokeObjectURL(link.href);
}

View file

@ -28,7 +28,7 @@ export interface AttributeDefinition {
description?: string; description?: string;
required?: boolean; required?: boolean;
default?: any; default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string; options?: Array<{ value: string | number; label: string }> | string;
validation?: any; validation?: any;
sortable?: boolean; sortable?: boolean;
filterable?: boolean; filterable?: boolean;

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,8 @@
*/ */
import React from 'react'; import React from 'react';
import { ACCESS_LEVEL_OPTIONS, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules'; import { _getAccessLevelOptions, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './AccessRules.module.css'; import styles from './AccessRules.module.css';
interface AccessLevelSelectProps { interface AccessLevelSelectProps {
@ -25,6 +26,8 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
showLabel = false, showLabel = false,
compact = false, compact = false,
}) => { }) => {
const { t } = useLanguage();
const accessLevelOptions = _getAccessLevelOptions(t);
const currentColor = getAccessLevelColor(value); const currentColor = getAccessLevelColor(value);
return ( return (
@ -42,7 +45,7 @@ export const AccessLevelSelect: React.FC<AccessLevelSelectProps> = ({
color: currentColor, color: currentColor,
}} }}
> >
{ACCESS_LEVEL_OPTIONS.map(option => ( {accessLevelOptions.map(option => (
<option <option
key={option.value} key={option.value}
value={option.value} value={option.value}

View file

@ -37,6 +37,8 @@ import { AccessLevelSelect } from './AccessLevelSelect';
import { AccessRulesTable } from './AccessRulesTable'; import { AccessRulesTable } from './AccessRulesTable';
import styles from './AccessRules.module.css'; import styles from './AccessRules.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================= // =============================================================================
// TYPES // TYPES
// ============================================================================= // =============================================================================
@ -66,6 +68,7 @@ interface RuleCardProps {
} }
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => { const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
const { t } = useLanguage();
const isDataRule = rule.context === 'DATA'; const isDataRule = rule.context === 'DATA';
return ( return (
@ -83,7 +86,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
<button <button
className={`${styles.iconButton} ${styles.danger}`} className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)} onClick={() => onDelete(rule.id)}
title="Regel löschen" title={t('Regel löschen')}
> >
<FaTrash /> <FaTrash />
</button> </button>
@ -94,7 +97,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
<div className={styles.permissionsGrid}> <div className={styles.permissionsGrid}>
{/* View Toggle */} {/* View Toggle */}
<div className={styles.permissionItem}> <div className={styles.permissionItem}>
<span className={styles.permissionLabel}>View</span> <span className={styles.permissionLabel}>{t('Ansicht')}</span>
<div className={styles.viewToggle}> <div className={styles.viewToggle}>
<input <input
type="checkbox" type="checkbox"
@ -110,7 +113,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
{isDataRule ? ( {isDataRule ? (
<> <>
<div className={styles.permissionItem}> <div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Read</span> <span className={styles.permissionLabel}>{t('Lesen')}</span>
<AccessLevelSelect <AccessLevelSelect
value={rule.read} value={rule.read}
onChange={(value) => onUpdate(rule.id, { read: value })} onChange={(value) => onUpdate(rule.id, { read: value })}
@ -119,7 +122,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/> />
</div> </div>
<div className={styles.permissionItem}> <div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Create</span> <span className={styles.permissionLabel}>{t('Erstellen')}</span>
<AccessLevelSelect <AccessLevelSelect
value={rule.create} value={rule.create}
onChange={(value) => onUpdate(rule.id, { create: value })} onChange={(value) => onUpdate(rule.id, { create: value })}
@ -128,7 +131,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/> />
</div> </div>
<div className={styles.permissionItem}> <div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Update</span> <span className={styles.permissionLabel}>{t('Bearbeiten')}</span>
<AccessLevelSelect <AccessLevelSelect
value={rule.update} value={rule.update}
onChange={(value) => onUpdate(rule.id, { update: value })} onChange={(value) => onUpdate(rule.id, { update: value })}
@ -137,7 +140,7 @@ const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete
/> />
</div> </div>
<div className={styles.permissionItem}> <div className={styles.permissionItem}>
<span className={styles.permissionLabel}>Delete</span> <span className={styles.permissionLabel}>{t('Löschen')}</span>
<AccessLevelSelect <AccessLevelSelect
value={rule.delete} value={rule.delete}
onChange={(value) => onUpdate(rule.id, { delete: value })} onChange={(value) => onUpdate(rule.id, { delete: value })}
@ -167,6 +170,7 @@ interface AddRuleFormProps {
} }
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => { const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, onAdd, onCancel }) => {
const { t } = useLanguage();
const [item, setItem] = useState(''); const [item, setItem] = useState('');
const [useCustom, setUseCustom] = useState(false); const [useCustom, setUseCustom] = useState(false);
const [view, setView] = useState(true); const [view, setView] = useState(true);
@ -210,20 +214,20 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
}; };
const getLabel = (obj: CatalogObject): string => { const getLabel = (obj: CatalogObject): string => {
return obj.label.de || obj.label.en || obj.objectKey; return (typeof obj.label === 'string' ? obj.label : '') || obj.objectKey;
}; };
return ( return (
<form className={styles.addRuleForm} onSubmit={handleSubmit}> <form className={styles.addRuleForm} onSubmit={handleSubmit}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
<div className={styles.objectSelectorLabel}> <div className={styles.objectSelectorLabel}>
<label className={styles.formLabel}>Objekt auswählen</label> <label className={styles.formLabel}>{t('select object')}</label>
<button <button
type="button" type="button"
className={styles.toggleCustomButton} className={styles.toggleCustomButton}
onClick={() => setUseCustom(!useCustom)} onClick={() => setUseCustom(!useCustom)}
> >
{useCustom ? '← Aus Katalog wählen' : 'Freie Eingabe →'} {useCustom ? t('select from catalog') : t('free input')}
</button> </button>
</div> </div>
@ -242,7 +246,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
onChange={(e) => setItem(e.target.value)} onChange={(e) => setItem(e.target.value)}
className={styles.formSelect} className={styles.formSelect}
> >
<option value="">-- Global (alle Objekte) --</option> <option value="">{t('global all objects')}</option>
{Object.entries(groupedObjects).map(([feature, objs]) => ( {Object.entries(groupedObjects).map(([feature, objs]) => (
<optgroup key={feature} label={feature.toUpperCase()}> <optgroup key={feature} label={feature.toUpperCase()}>
{objs.map(obj => ( {objs.map(obj => (
@ -256,7 +260,9 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
)} )}
<span className={styles.formHint}> <span className={styles.formHint}>
Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*). {t(
'Leer lassen für globale Regel. Längster Match gewinnt bei Wildcards (z.B. data.feature.trustee.*).'
)}
</span> </span>
</div> </div>
@ -268,7 +274,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
onChange={(e) => setView(e.target.checked)} onChange={(e) => setView(e.target.checked)}
style={{ marginRight: '0.5rem' }} style={{ marginRight: '0.5rem' }}
/> />
Sichtbar (View) {t('Sichtbar (Ansicht)')}
</label> </label>
</div> </div>
@ -277,16 +283,21 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
{/* Header Row */} {/* Header Row */}
<div className={styles.matrixHeader}> <div className={styles.matrixHeader}>
<div className={styles.matrixLabel}></div> <div className={styles.matrixLabel}></div>
<div className={styles.matrixGroup}>Eigene (m)</div> <div className={styles.matrixGroup}>{t('own')}</div>
<div className={styles.matrixGroup}>Gruppe (g)</div> <div className={styles.matrixGroup}>{t('group')}</div>
<div className={styles.matrixGroup}>Alle (a)</div> <div className={styles.matrixGroup}>{t('Alle')}</div>
</div> </div>
{/* CRUD Rows */} {/* CRUD Rows */}
{(['create', 'read', 'update', 'delete'] as const).map(op => { {(['create', 'read', 'update', 'delete'] as const).map(op => {
const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read; const value = op === 'delete' ? del : op === 'create' ? create : op === 'update' ? update : read;
const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead; const setValue = op === 'delete' ? setDel : op === 'create' ? setCreate : op === 'update' ? setUpdate : setRead;
const labels = { create: 'Create', read: 'Read', update: 'Update', delete: 'Delete' }; const labels = {
create: t('Erstellen'),
read: t('Lesen'),
update: t('Bearbeiten'),
delete: t('Löschen'),
};
return ( return (
<div key={op} className={styles.matrixRow}> <div key={op} className={styles.matrixRow}>
@ -306,7 +317,7 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
setValue(hierarchy[idx - 1] || 'n'); setValue(hierarchy[idx - 1] || 'n');
} }
}} }}
title={`${labels[op]} - ${level === 'm' ? 'Eigene' : level === 'g' ? 'Gruppe' : 'Alle'}`} title={`${labels[op]} - ${level === 'm' ? t('Eigene') : level === 'g' ? t('Gruppe') : t('Alle')}`}
/> />
</div> </div>
))} ))}
@ -318,10 +329,10 @@ const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, availableObjects, on
<div className={styles.formActions}> <div className={styles.formActions}>
<button type="button" className={styles.secondaryButton} onClick={onCancel}> <button type="button" className={styles.secondaryButton} onClick={onCancel}>
Abbrechen {t('Abbrechen')}
</button> </button>
<button type="submit" className={styles.primaryButton}> <button type="submit" className={styles.primaryButton}>
<FaPlus /> Hinzufügen <FaPlus /> {t('Hinzufügen')}
</button> </button>
</div> </div>
</form> </form>
@ -351,6 +362,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
onDelete, onDelete,
onAdd, onAdd,
}) => { }) => {
const { t } = useLanguage();
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA const [useTableView, setUseTableView] = useState(context === 'DATA'); // Default to table for DATA
@ -369,9 +381,12 @@ const RulesSection: React.FC<RulesSectionProps> = ({
const getEmptyText = () => { const getEmptyText = () => {
switch (context) { switch (context) {
case 'DATA': return 'Keine Daten-Regeln definiert'; case 'DATA':
case 'UI': return 'Keine UI-Regeln definiert'; return t('Keine Daten-Regeln definiert');
case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert'; case 'UI':
return t('Keine UI-Regeln definiert');
case 'RESOURCE':
return t('Keine Ressourcen-Regeln definiert');
} }
}; };
@ -380,7 +395,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
{!readOnly && !showAddForm && ( {!readOnly && !showAddForm && (
<div className={styles.sectionHeader}> <div className={styles.sectionHeader}>
<span className={styles.sectionTitle}> <span className={styles.sectionTitle}>
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'} {rules.length} {rules.length === 1 ? t('Regel') : t('Regeln')}
</span> </span>
<div className={styles.headerActions}> <div className={styles.headerActions}>
{/* View Toggle */} {/* View Toggle */}
@ -389,14 +404,14 @@ const RulesSection: React.FC<RulesSectionProps> = ({
<button <button
className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`} className={`${styles.viewToggleButton} ${useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(true)} onClick={() => setUseTableView(true)}
title="Tabellenansicht" title={t('Tabellenansicht')}
> >
<FaThList /> <FaThList />
</button> </button>
<button <button
className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`} className={`${styles.viewToggleButton} ${!useTableView ? styles.active : ''}`}
onClick={() => setUseTableView(false)} onClick={() => setUseTableView(false)}
title="Kartenansicht" title={t('Kartenansicht')}
> >
<FaTh /> <FaTh />
</button> </button>
@ -406,7 +421,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
className={styles.addButton} className={styles.addButton}
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
> >
<FaPlus /> Neue Regel <FaPlus /> {t('Neue Regel')}
</button> </button>
</div> </div>
</div> </div>
@ -427,7 +442,7 @@ const RulesSection: React.FC<RulesSectionProps> = ({
<p className={styles.emptyText}>{getEmptyText()}</p> <p className={styles.emptyText}>{getEmptyText()}</p>
{!readOnly && ( {!readOnly && (
<p className={styles.emptyHint}> <p className={styles.emptyHint}>
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen. {t('Klicken Sie auf „Neue Regel“, um eine Berechtigung hinzuzufügen.')}
</p> </p>
)} )}
</div> </div>
@ -465,6 +480,7 @@ interface JsonEditorProps {
} }
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => { const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
const { t } = useLanguage();
const [jsonText, setJsonText] = useState(''); const [jsonText, setJsonText] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -477,7 +493,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
try { try {
const parsed = JSON.parse(jsonText); const parsed = JSON.parse(jsonText);
if (!Array.isArray(parsed)) { if (!Array.isArray(parsed)) {
throw new Error('JSON muss ein Array sein'); throw new Error(t('JSON muss ein Array sein'));
} }
setError(null); setError(null);
onApply(parsed); onApply(parsed);
@ -497,8 +513,9 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
/> />
{error && <div className={styles.jsonError}>{error}</div>} {error && <div className={styles.jsonError}>{error}</div>}
<p className={styles.jsonHint}> <p className={styles.jsonHint}>
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. {t(
Änderungen werden erst nach Klick auf "Anwenden" übernommen. 'Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON. Änderungen werden erst nach Klick auf „Anwenden“ übernommen.'
)}
</p> </p>
{!readOnly && ( {!readOnly && (
<div className={styles.formActions}> <div className={styles.formActions}>
@ -508,7 +525,7 @@ const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) =>
onClick={handleApply} onClick={handleApply}
disabled={!!error} disabled={!!error}
> >
JSON anwenden {t('JSON anwenden')}
</button> </button>
</div> </div>
)} )}
@ -531,6 +548,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
featureCode, featureCode,
}) => { }) => {
const { showError } = useToast(); const { showError } = useToast();
const { t } = useLanguage();
const { const {
rules, rules,
loading, loading,
@ -602,7 +620,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
setHasChanges(false); setHasChanges(false);
onSave?.(); onSave?.();
} else { } else {
showError('Fehler', result.error || 'Fehler beim Speichern'); showError(t('Fehler'), result.error || t('Fehler beim Speichern'));
} }
}; };
@ -628,9 +646,9 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
// Render tabs // Render tabs
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [ const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length }, { id: 'DATA', label: t('data'), icon: <FaTable />, count: groupedRules.DATA.length },
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length }, { id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length }, { id: 'RESOURCE', label: t('resources'), icon: <FaServer />, count: groupedRules.RESOURCE.length },
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length }, { id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
]; ];
@ -639,7 +657,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
<div className={styles.accessRulesEditor}> <div className={styles.accessRulesEditor}>
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner} /> <div className={styles.spinner} />
<span>Lade Berechtigungen...</span> <span>{t('loading permissions')}</span>
</div> </div>
</div> </div>
); );
@ -650,7 +668,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
<div className={styles.editorHeader}> <div className={styles.editorHeader}>
<h3 className={styles.editorTitle}> <h3 className={styles.editorTitle}>
Berechtigungen{roleName ? `: ${roleName}` : ''} Berechtigungen{roleName ? `: ${roleName}` : ''}
{isTemplate && <span className={styles.templateBadge}>Template</span>} {isTemplate && <span className={styles.templateBadge}>{t('Vorlage')}</span>}
</h3> </h3>
{!readOnly && hasChanges && ( {!readOnly && hasChanges && (
<div className={styles.headerActions}> <div className={styles.headerActions}>

View file

@ -10,6 +10,8 @@ import { FaTable, FaDesktop, FaServer, FaTrash } from 'react-icons/fa';
import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules'; import { type AccessRule, type RuleContext, type AccessLevel } from '../../hooks/useAccessRules';
import styles from './AccessRules.module.css'; import styles from './AccessRules.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
// ============================================================================= // =============================================================================
// TYPES // TYPES
// ============================================================================= // =============================================================================
@ -74,6 +76,9 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
onUpdate, onUpdate,
onDelete, onDelete,
}) => { }) => {
const { t } = useLanguage();
const opTitle = (op: 'create' | 'read' | 'update' | 'delete') =>
({ create: t('Erstellen'), read: t('Lesen'), update: t('Bearbeiten'), delete: t('Löschen') })[op];
const handleLevelToggle = ( const handleLevelToggle = (
field: 'read' | 'create' | 'update' | 'delete', field: 'read' | 'create' | 'update' | 'delete',
targetLevel: 'm' | 'g' | 'a', targetLevel: 'm' | 'g' | 'a',
@ -109,7 +114,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={rule.view} checked={rule.view}
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })} onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
disabled={readOnly} disabled={readOnly}
title="Sichtbar" title={t('Sichtbar')}
/> />
</td> </td>
@ -124,7 +129,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'm')} checked={hasLevel(rule[op] as AccessLevel, 'm')}
onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)} onChange={(e) => handleLevelToggle(op, 'm', e.target.checked)}
disabled={readOnly} disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Eigene`} title={`${opTitle(op)} - ${t('Eigene')}`}
/> />
</td> </td>
))} ))}
@ -137,7 +142,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'g')} checked={hasLevel(rule[op] as AccessLevel, 'g')}
onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)} onChange={(e) => handleLevelToggle(op, 'g', e.target.checked)}
disabled={readOnly} disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Gruppe`} title={`${opTitle(op)} - ${t('Gruppe')}`}
/> />
</td> </td>
))} ))}
@ -150,7 +155,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
checked={hasLevel(rule[op] as AccessLevel, 'a')} checked={hasLevel(rule[op] as AccessLevel, 'a')}
onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)} onChange={(e) => handleLevelToggle(op, 'a', e.target.checked)}
disabled={readOnly} disabled={readOnly}
title={`${op.charAt(0).toUpperCase() + op.slice(1)} - Alle`} title={`${opTitle(op)} - ${t('Alle')}`}
/> />
</td> </td>
))} ))}
@ -163,7 +168,7 @@ const AccessRuleRow: React.FC<AccessRuleRowProps> = ({
<button <button
className={`${styles.iconButton} ${styles.danger}`} className={`${styles.iconButton} ${styles.danger}`}
onClick={() => onDelete(rule.id)} onClick={() => onDelete(rule.id)}
title="Regel löschen" title={t('Regel löschen')}
> >
<FaTrash /> <FaTrash />
</button> </button>
@ -184,6 +189,7 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
onUpdate, onUpdate,
onDelete, onDelete,
}) => { }) => {
const { t } = useLanguage();
const isDataContext = context === 'DATA'; const isDataContext = context === 'DATA';
if (rules.length === 0) { if (rules.length === 0) {
@ -195,13 +201,13 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<table className={styles.accessRulesTable}> <table className={styles.accessRulesTable}>
<thead> <thead>
<tr> <tr>
<th className={styles.colObject}>Objekt (Dot-Notation)</th> <th className={styles.colObject}>{t('object dot notation')}</th>
<th className={styles.colView}>View</th> <th className={styles.colView}>{t('Ansicht')}</th>
{isDataContext && ( {isDataContext && (
<> <>
<th className={styles.colGroupHeader} colSpan={4}>Eigene (m)</th> <th className={styles.colGroupHeader} colSpan={4}>{t('own')}</th>
<th className={styles.colGroupHeader} colSpan={4}>Gruppe (g)</th> <th className={styles.colGroupHeader} colSpan={4}>{t('group')}</th>
<th className={styles.colGroupHeader} colSpan={4}>Alle (a)</th> <th className={styles.colGroupHeader} colSpan={4}>{t('Alle')}</th>
</> </>
)} )}
<th className={styles.colActions}></th> <th className={styles.colActions}></th>
@ -210,18 +216,18 @@ export const AccessRulesTable: React.FC<AccessRulesTableProps> = ({
<tr className={styles.subHeader}> <tr className={styles.subHeader}>
<th></th> <th></th>
<th></th> <th></th>
<th title="Create">C</th> <th title={t('Erstellen')}>C</th>
<th title="Read">R</th> <th title={t('Lesen')}>R</th>
<th title="Update">U</th> <th title={t('Bearbeiten')}>U</th>
<th title="Delete">D</th> <th title={t('Löschen')}>D</th>
<th title="Create">C</th> <th title={t('Erstellen')}>C</th>
<th title="Read">R</th> <th title={t('Lesen')}>R</th>
<th title="Update">U</th> <th title={t('Bearbeiten')}>U</th>
<th title="Delete">D</th> <th title={t('Löschen')}>D</th>
<th title="Create">C</th> <th title={t('Erstellen')}>C</th>
<th title="Read">R</th> <th title={t('Lesen')}>R</th>
<th title="Update">U</th> <th title={t('Bearbeiten')}>U</th>
<th title="Delete">D</th> <th title={t('Löschen')}>D</th>
<th></th> <th></th>
</tr> </tr>
)} )}

View file

@ -1,289 +0,0 @@
/* ActionsPanel Styles */
.panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
overflow: hidden;
}
.header {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
}
.title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.searchBox {
display: flex;
align-items: center;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.searchIcon {
color: var(--text-secondary, #666);
margin-right: 0.5rem;
font-size: 0.875rem;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
color: var(--text-primary, #333);
outline: none;
}
.searchInput::placeholder {
color: var(--text-tertiary, #999);
}
.actionsList {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.loading,
.error,
.empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
color: var(--error-color, #dc3545);
}
.retryButton {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.retryButton:hover {
background: var(--primary-hover, #0056b3);
}
/* Method Groups */
.methodGroup {
margin-bottom: 0.5rem;
background: var(--bg-primary, #ffffff);
border-radius: 6px;
overflow: hidden;
}
.methodHeader {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #333);
transition: background 0.2s;
}
.methodHeader:hover {
background: var(--bg-hover, #f0f0f0);
}
.methodHeader svg {
margin-right: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.methodName {
flex: 1;
text-transform: capitalize;
}
.methodCount {
background: var(--primary-color, #007bff);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
/* Method Actions */
.methodActions {
border-top: 1px solid var(--border-color, #e0e0e0);
}
.actionItem {
border-bottom: 1px solid var(--border-light, #f0f0f0);
}
.actionItem:last-child {
border-bottom: none;
}
.actionHeader {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.actionHeader:hover {
background: var(--bg-hover, #f5f5f5);
}
.actionInfo {
flex: 1;
min-width: 0;
}
.actionName {
display: block;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary, #333);
}
.actionDesc {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copyButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.2s;
}
.copyButton:hover {
background: var(--primary-color, #007bff);
border-color: var(--primary-color, #007bff);
color: white;
}
/* Action Details */
.actionDetails {
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f8f9fa);
border-top: 1px solid var(--border-light, #f0f0f0);
}
.actionDetails h5 {
margin: 0 0 0.5rem 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
}
/* Parameters */
.parameters {
margin-bottom: 1rem;
}
.parameters ul {
margin: 0;
padding: 0;
list-style: none;
}
.param {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.paramName {
font-weight: 500;
color: var(--text-primary, #333);
}
.required {
color: var(--error-color, #dc3545);
margin-left: 2px;
}
.paramType {
font-family: monospace;
font-size: 0.75rem;
background: var(--bg-code, #e9ecef);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: var(--text-secondary, #666);
}
.paramDesc {
width: 100%;
font-size: 0.75rem;
color: var(--text-tertiary, #888);
}
/* Example JSON */
.exampleJson {
margin-bottom: 1rem;
}
.exampleJson pre {
margin: 0;
padding: 0.75rem;
background: var(--bg-code, #1e1e1e);
color: var(--text-code, #d4d4d4);
border-radius: 4px;
font-size: 0.75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.insertButton {
width: 100%;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s;
}
.insertButton:hover {
background: var(--primary-hover, #0056b3);
}

View file

@ -1,216 +0,0 @@
/**
* ActionsPanel
*
* Displays available workflow actions for copy/paste into templates.
* Groups actions by method and shows parameters + example JSON.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useWorkflowActions, type WorkflowAction } from '../../hooks/useAutomations';
import { FaSearch, FaCopy, FaChevronDown, FaChevronRight, FaCheck } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import styles from './ActionsPanel.module.css';
interface ActionsPanelProps {
/** Callback when action JSON is inserted (optional) */
onInsert?: (actionJson: string) => void;
/** Callback when action JSON is copied (optional) */
onCopy?: (actionJson: string) => void;
}
export const ActionsPanel: React.FC<ActionsPanelProps> = ({ onInsert, onCopy }) => {
const { actions, loading, error, fetchActions } = useWorkflowActions();
const { showSuccess } = useToast();
const [filter, setFilter] = useState('');
const [expandedMethods, setExpandedMethods] = useState<Set<string>>(new Set());
const [expandedAction, setExpandedAction] = useState<string | null>(null);
const [copiedAction, setCopiedAction] = useState<string | null>(null);
useEffect(() => {
fetchActions();
}, [fetchActions]);
// Filter actions by search term
const filteredActions = useMemo(() => {
if (!filter) return actions;
const lower = filter.toLowerCase();
return actions.filter(a =>
a.method.toLowerCase().includes(lower) ||
a.action.toLowerCase().includes(lower) ||
a.description.toLowerCase().includes(lower) ||
a.actionId.toLowerCase().includes(lower)
);
}, [actions, filter]);
// Group actions by method
const groupedActions = useMemo(() => {
const groups: Record<string, WorkflowAction[]> = {};
filteredActions.forEach(action => {
if (!groups[action.method]) {
groups[action.method] = [];
}
groups[action.method].push(action);
});
return groups;
}, [filteredActions]);
// Toggle method expansion
const toggleMethod = (method: string) => {
setExpandedMethods(prev => {
const newSet = new Set(prev);
if (newSet.has(method)) {
newSet.delete(method);
} else {
newSet.add(method);
}
return newSet;
});
};
// Toggle action details
const toggleAction = (actionId: string) => {
setExpandedAction(prev => prev === actionId ? null : actionId);
};
// Copy action JSON to clipboard
const handleCopy = async (action: WorkflowAction) => {
const json = JSON.stringify(action.exampleJson, null, 2);
try {
await navigator.clipboard.writeText(json);
setCopiedAction(action.actionId);
setTimeout(() => setCopiedAction(null), 2000);
showSuccess('JSON kopiert');
onCopy?.(json);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Insert action JSON
const handleInsert = (action: WorkflowAction) => {
const json = JSON.stringify(action.exampleJson, null, 2);
onInsert?.(json);
};
if (loading) {
return (
<div className={styles.panel}>
<div className={styles.loading}>Lade Actions...</div>
</div>
);
}
if (error) {
return (
<div className={styles.panel}>
<div className={styles.error}>Fehler: {error}</div>
<button className={styles.retryButton} onClick={() => fetchActions()}>
Erneut versuchen
</button>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.header}>
<h3 className={styles.title}>Verfügbare Actions</h3>
<div className={styles.searchBox}>
<FaSearch className={styles.searchIcon} />
<input
type="text"
placeholder="Suchen..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className={styles.searchInput}
/>
</div>
</div>
<div className={styles.actionsList}>
{Object.keys(groupedActions).length === 0 ? (
<div className={styles.empty}>Keine Actions gefunden</div>
) : (
Object.entries(groupedActions).map(([method, methodActions]) => (
<div key={method} className={styles.methodGroup}>
<button
className={styles.methodHeader}
onClick={() => toggleMethod(method)}
>
{expandedMethods.has(method) ? <FaChevronDown /> : <FaChevronRight />}
<span className={styles.methodName}>{method}</span>
<span className={styles.methodCount}>{methodActions.length}</span>
</button>
{expandedMethods.has(method) && (
<div className={styles.methodActions}>
{methodActions.map(action => (
<div key={action.actionId} className={styles.actionItem}>
<div
className={styles.actionHeader}
onClick={() => toggleAction(action.actionId)}
>
<div className={styles.actionInfo}>
<span className={styles.actionName}>{action.action}</span>
<span className={styles.actionDesc}>{action.description}</span>
</div>
<button
className={styles.copyButton}
onClick={(e) => { e.stopPropagation(); handleCopy(action); }}
title="JSON kopieren"
>
{copiedAction === action.actionId ? <FaCheck /> : <FaCopy />}
</button>
</div>
{expandedAction === action.actionId && (
<div className={styles.actionDetails}>
{action.parameters.length > 0 && (
<div className={styles.parameters}>
<h5>Parameter:</h5>
<ul>
{action.parameters.map(param => (
<li key={param.name} className={styles.param}>
<span className={styles.paramName}>
{param.name}
{param.required && <span className={styles.required}>*</span>}
</span>
<span className={styles.paramType}>{param.type}</span>
{param.description && (
<span className={styles.paramDesc}>{param.description}</span>
)}
</li>
))}
</ul>
</div>
)}
<div className={styles.exampleJson}>
<h5>Beispiel JSON:</h5>
<pre>{JSON.stringify(action.exampleJson, null, 2)}</pre>
</div>
{onInsert && (
<button
className={styles.insertButton}
onClick={() => handleInsert(action)}
>
In Template einfügen
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
))
)}
</div>
</div>
);
};
export default ActionsPanel;

View file

@ -1,2 +0,0 @@
export { ActionsPanel } from './ActionsPanel';
export { default } from './ActionsPanel';

View file

@ -1,464 +0,0 @@
/**
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchNodeTypes,
executeGraph,
fetchWorkflows,
fetchWorkflow,
createWorkflow,
updateWorkflow,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
} from '../../../api/automation2Api';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
const DEFAULT_INVOCATIONS = (): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], 'Jetzt ausführen');
interface Automation2FlowEditorProps {
instanceId: string;
language?: string;
/** When set, load this workflow on mount (e.g. from workflows list edit) */
initialWorkflowId?: string | null;
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
instanceId,
language = 'de',
initialWorkflowId,
}) => {
const { request } = useApiRequest();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const nodeOutputsPreview = useMemo(
() =>
buildNodeOutputsPreview(canvasNodes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
[canvasNodes, executeResult?.nodeOutputs]
);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : DEFAULT_INVOCATIONS();
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
return;
}
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
},
[nodeTypes, language]
);
const handleFromApiGraph = useCallback(
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations);
},
[applyGraphWithSync]
);
const handleExecute = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
return;
}
setExecuting(true);
setExecuteResult(null);
try {
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
return;
}
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = await promptInput('Workflow-Name:', {
title: 'Workflow speichern',
defaultValue: 'Neuer Workflow',
placeholder: 'Name des Workflows',
});
if (!label) {
setSaving(false);
return;
}
const created = await createWorkflow(request, instanceId, {
label: label.trim() || 'Neuer Workflow',
graph,
invocations,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse);
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setSaving(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations]);
const handleLoad = useCallback(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations);
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
} catch (err: unknown) {
setExecuteResult({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
);
const handleWorkflowSelect = useCallback(
(workflowId: string | null) => {
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}
},
[handleLoad, applyGraphWithSync]
);
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}, [applyGraphWithSync]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleNodeUpdate = useCallback(
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
setCanvasNodes((prev) =>
prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
);
},
[]
);
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = await fetchNodeTypes(request, instanceId, language);
setNodeTypes(data.nodeTypes);
setCategories(data.categories);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
setCategories([]);
} finally {
setLoading(false);
}
}, [instanceId, language, request]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const items = await fetchWorkflows(request, instanceId);
setWorkflows(items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [instanceId, request]);
useEffect(() => {
loadNodeTypes();
}, [loadNodeTypes]);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
useEffect(() => {
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId && nodeTypes.length > 0) {
handleWorkflowSelect(initialWorkflowId);
}
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect, nodeTypes.length]);
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, DEFAULT_INVOCATIONS());
}, [
loading,
nodeTypes.length,
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
applyGraphWithSync,
]);
const toggleCategory = useCallback((id: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const label =
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
setCanvasNodes((prev) => [
...prev,
{
id,
type: nodeTypeId,
x,
y,
label,
title: label,
color: nt.meta?.color,
inputs: nt.inputs ?? 1,
outputs: nt.outputs ?? 1,
parameters: {},
},
]);
},
[nodeTypes, language]
);
const renderSidebar = () => {
if (loading) {
return (
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
<p>Lade Node-Typen...</p>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.sidebar}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3>
</div>
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={loadNodeTypes}>
Erneut versuchen
</button>
</div>
</div>
);
}
return (
<NodeSidebar
nodeTypes={nodeTypes}
categories={categories}
filter={filter}
onFilterChange={setFilter}
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
/>
);
};
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.'].some((p) =>
selectedNode.type.startsWith(p)
);
return (
<div className={styles.container}>
{renderSidebar()}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onWorkflowSelect={handleWorkflowSelect}
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeResult={executeResult}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<FlowCanvas
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
onNodesChange={setCanvasNodes}
onConnectionsChange={setCanvasConnections}
onDropNodeType={handleDropNodeType}
getLabel={(node) => node.title ?? node.label ?? node.type}
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
/>
</div>
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
nodeOutputsPreview={nodeOutputsPreview}
nodeTypes={nodeTypes}
language={language}
>
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
onMergeNodeParameters={handleMergeNodeParameters}
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
/>
</Automation2DataFlowProvider>
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
</div>
);
};
export default Automation2FlowEditor;

View file

@ -1,130 +0,0 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
*/
import React from 'react';
import { FaCog, FaPlay, FaSpinner } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse } from '../../../api/automation2Api';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
executeResult: ExecuteGraphResponse | null;
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
saving,
executing,
hasNodes,
executeResult,
}) => (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
Workflow-Editor
</h4>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title="Workflow-Konfiguration (Einstieg / Starts)"
aria-label="Workflow-Konfiguration"
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
<button type="button" className={styles.retryButton} onClick={onNew}>
Neu
</button>
<button
type="button"
className={styles.retryButton}
onClick={onSave}
disabled={saving || !hasNodes}
>
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
</button>
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value=""> Workflow laden </option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
disabled={executing || !hasNodes}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
Ausführen
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
Ausführen
</>
)}
</button>
</div>
{executeResult && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
>
{executeResult.success ? (
<> Ausführung abgeschlossen.</>
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den
Task zu bearbeiten.
</>
) : (
<> {executeResult.error ?? 'Unbekannter Fehler'}</>
)}
</div>
)}
</div>
);

View file

@ -1,49 +0,0 @@
/**
* NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups.
*/
import React from 'react';
import type { NodeType } from '../../../api/automation2Api';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css';
interface NodeListItemProps {
node: NodeType;
language: string;
getLabel: GetLabelFn;
getCategoryIcon?: (categoryId: string) => React.ReactNode;
}
export const NodeListItem: React.FC<NodeListItemProps> = ({
node,
language,
getLabel,
getCategoryIcon: getIcon = getCategoryIcon,
}) => (
<div
className={styles.nodeItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div
className={styles.nodeItemIcon}
style={{
backgroundColor: node.meta?.color
? `${node.meta.color}20`
: 'var(--bg-tertiary, #e9ecef)',
color: node.meta?.color ?? 'var(--text-secondary, #666)',
}}
>
{getIcon(node.category)}
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
<span className={styles.nodeItemDesc}>{getLabel(node.description, language)}</span>
</div>
</div>
);

View file

@ -1,90 +0,0 @@
/**
* AI node config - prompt, query, document options per node type.
* Prompt/query fields support static value or node reference (Data Picker).
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { DynamicValueField } from '../shared/DynamicValueField';
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select' | 'dynamic'; options?: string[] }[]> = {
'ai.prompt': [{ label: 'Prompt', key: 'prompt', type: 'textarea' }],
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'dynamic' }],
'ai.summarizeDocument': [
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
],
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
'ai.convertDocument': [
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
],
'ai.generateDocument': [{ label: 'Prompt', key: 'prompt', type: 'dynamic' }],
'ai.generateCode': [
{ label: 'Prompt', key: 'prompt', type: 'dynamic' },
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
],
};
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
return (
<>
{fields.map((f) => {
if (f.type === 'dynamic') {
return (
<DynamicValueField
key={f.key}
paramKey={f.key}
value={params[f.key]}
onChange={updateParam}
label={f.label}
fieldType="textarea"
rows={4}
placeholder={f.label}
/>
);
}
if (f.type === 'textarea') {
return (
<div key={f.key}>
<label>{f.label}</label>
<textarea
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
rows={4}
/>
</div>
);
}
if (f.type === 'select') {
return (
<div key={f.key}>
<label>{f.label}</label>
<select
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
onChange={(e) => updateParam(f.key, e.target.value)}
>
{(f.options ?? []).map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
return (
<div key={f.key}>
<label>{f.label}</label>
<input
value={(params[f.key] as string) ?? ''}
onChange={(e) => updateParam(f.key, e.target.value)}
placeholder={f.label}
/>
</div>
);
})}
</>
);
};

View file

@ -1,27 +0,0 @@
/**
* Approval node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>Beschreibung</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder="Was genehmigt werden soll"
/>
</div>
</>
);

View file

@ -1,29 +0,0 @@
/**
* Comment node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder="Kommentar eingeben..."
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={(params.required as boolean) ?? true}
onChange={(e) => updateParam('required', e.target.checked)}
/>
Pflichtfeld
</label>
</div>
</>
);

View file

@ -1,33 +0,0 @@
/**
* Confirmation node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder="Möchten Sie bestätigen?"
/>
</div>
<div>
<label>Bestätigen-Button</label>
<input
value={(params.confirmLabel as string) ?? 'Confirm'}
onChange={(e) => updateParam('confirmLabel', e.target.value)}
/>
</div>
<div>
<label>Ablehnen-Button</label>
<input
value={(params.rejectLabel as string) ?? 'Reject'}
onChange={(e) => updateParam('rejectLabel', e.target.value)}
/>
</div>
</>
);

View file

@ -1,240 +0,0 @@
/**
* Email node config - connection selector, folder dropdown, query, subject, body.
*/
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'email.checkEmail',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [folders, setFolders] = useState<BrowseEntry[]>([]);
const [loading, setLoading] = useState(false);
const [foldersLoading, setFoldersLoading] = useState(false);
useEffect(() => {
if (instanceId && request) {
setLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setLoading(false));
}
}, [instanceId, request]);
const connectionId = (params.connectionId as string) ?? '';
const selectedConn = connections.find((c) => c.id === connectionId);
const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
useEffect(() => {
if (instanceId && request && connectionId) {
setFoldersLoading(true);
fetchBrowse(request, instanceId, connectionId, mailService, '/')
.then((r) => setFolders(r.items.filter((e) => e.isFolder)))
.catch(() => setFolders([]))
.finally(() => setFoldersLoading(false));
} else {
setFolders([]);
}
}, [instanceId, request, connectionId, mailService]);
const isDraft = nodeType === 'email.draftEmail';
const isSearch = nodeType === 'email.searchEmail';
const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
return (
<>
<div>
<label>Account</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalEmail ?? c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{!isDraft && (
<div>
<label>Folder</label>
<select
value={folderValue}
onChange={(e) => updateParam('folder', e.target.value)}
disabled={foldersLoading || !connectionId}
>
<option value="">
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
</option>
{isSearch && <option value="All">All</option>}
{folders.length > 0
? folders.map((f) => {
const folderId = (f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id || '';
const value = folderId || f.name;
if (!value) return null;
return (
<option key={value} value={value}>
{f.name}
</option>
);
})
: !isSearch && (
<>
<option value="Inbox">Inbox</option>
<option value="Drafts">Drafts</option>
<option value="SentItems">Sent Items</option>
<option value="DeletedItems">Deleted Items</option>
<option value="JunkEmail">Junk Email</option>
</>
)}
{folderValue &&
!folders.some(
(f) =>
((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
) &&
folderValue !== 'All' && (
<option value={folderValue}>{folderValue}</option>
)}
</select>
</div>
)}
{isSearch && (
<>
<div>
<label>Search query (optional)</label>
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder="General search term (subject, body, from)"
/>
</div>
<div>
<label>From address (optional)</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
/>
</div>
<div>
<label>To address (optional)</label>
<input
value={(params.toAddress as string) ?? ''}
onChange={(e) => updateParam('toAddress', e.target.value)}
placeholder="e.g. recipient@example.com"
/>
</div>
<div>
<label>Subject contains (optional)</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
/>
</div>
<div>
<label>Body/content contains (optional)</label>
<input
value={(params.bodyContains as string) ?? ''}
onChange={(e) => updateParam('bodyContains', e.target.value)}
placeholder="Word or phrase in email body"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="searchHasAttachment"
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
</div>
<div>
<label>Limit</label>
<input
type="number"
value={(params.limit as number) ?? 100}
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
/>
</div>
</>
)}
{nodeType === 'email.checkEmail' && (
<>
<div>
<label>From address (optional)</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder="e.g. sender@example.com"
/>
</div>
<div>
<label>Subject contains (optional)</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder="Word or phrase in subject"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id="hasAttachment"
checked={!!(params.hasAttachment as boolean)}
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
/>
<label htmlFor="hasAttachment">Only emails with attachment</label>
</div>
<div>
<label>Limit</label>
<input
type="number"
value={(params.limit as number) ?? 100}
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
/>
</div>
</>
)}
{isDraft && (
<>
<div>
<label>Subject</label>
<input
value={(params.subject as string) ?? ''}
onChange={(e) => updateParam('subject', e.target.value)}
placeholder="Email subject (or leave empty if connected to AI node above)"
/>
</div>
<div>
<label>Body</label>
<textarea
value={(params.body as string) ?? ''}
onChange={(e) => updateParam('body', e.target.value)}
placeholder="Email body (or leave empty if connected to AI node above)"
rows={4}
/>
</div>
<div>
<label>To (optional)</label>
<input
value={(params.to as string) ?? ''}
onChange={(e) => updateParam('to', e.target.value)}
placeholder="Recipient(s) (or from AI when connected)"
/>
</div>
</>
)}
</>
);
};

View file

@ -1,121 +0,0 @@
/**
* File Create node config - multiple content sources, output format, title, template, language.
* Contents are concatenated in order (nacheinander geschrieben).
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { RefSourceSelect } from '../shared/RefSourceSelect';
import { isRef, type DataRef } from '../shared/dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css';
const OUTPUT_FORMATS = ['docx', 'pdf', 'txt', 'md', 'html', 'xlsx', 'csv', 'json'];
const TEMPLATE_OPTIONS = ['default', 'corporate', 'minimal'];
const LANGUAGES = ['de', 'en', 'fr', 'it', 'es'];
function normalizeContentSources(v: unknown): (DataRef | null)[] {
if (Array.isArray(v)) {
return v.map((x) => (isRef(x) ? x : null));
}
if (isRef(v)) return [v];
return [];
}
export const FileCreateNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const contentSources = normalizeContentSources(params.contentSources ?? params.contentSource ?? []);
const setContentSources = (next: (DataRef | null)[]) => {
updateParam('contentSources', next);
if (params.contentSource !== undefined) updateParam('contentSource', undefined);
};
const setItem = (index: number, ref: DataRef | null) => {
const next = [...contentSources];
next[index] = ref;
setContentSources(next);
};
const addItem = () => setContentSources([...contentSources, null]);
const removeItem = (index: number) => setContentSources(contentSources.filter((_, i) => i !== index));
return (
<>
<div className={styles.fileCreateContentSources}>
<label>Inhalte (welche Kontexte nacheinander in die Datei?)</label>
{contentSources.map((ref, i) => (
<div key={i} className={styles.contentSourceRow}>
<RefSourceSelect
value={ref}
onChange={(r) => setItem(i, r)}
placeholder="Quelle wählen…"
/>
<button
type="button"
className={styles.contentSourceRemoveBtn}
onClick={() => removeItem(i)}
title="Entfernen"
aria-label="Inhalt entfernen"
>
×
</button>
</div>
))}
<button type="button" className={styles.contentSourceAddBtn} onClick={addItem}>
+ Inhalt hinzufügen
</button>
{contentSources.length === 0 && (
<p className={styles.dynamicValueEmptyHint}>
Leer = Kontext vom verbundenen Node. Fügen Sie Inhalte hinzu, um mehrere Quellen zu kombinieren.
</p>
)}
</div>
<div>
<label>Ausgabeformat</label>
<select
value={(params.outputFormat as string) ?? 'docx'}
onChange={(e) => updateParam('outputFormat', e.target.value)}
>
{OUTPUT_FORMATS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Dokumenttitel"
/>
</div>
<div>
<label>Vorlage / Stil</label>
<select
value={(params.templateName as string) ?? 'default'}
onChange={(e) => updateParam('templateName', e.target.value)}
>
{TEMPLATE_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label>Sprache</label>
<select
value={(params.language as string) ?? 'de'}
onChange={(e) => updateParam('language', e.target.value)}
>
{LANGUAGES.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
</>
);
};

View file

@ -1,18 +0,0 @@
/**
* Review node config - content reference supports static value or node reference.
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { DynamicValueField } from '../shared/DynamicValueField';
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
<DynamicValueField
paramKey="contentRef"
value={params.contentRef}
onChange={updateParam}
label="Content-Referenz"
fieldType="input"
placeholder="{{nodeId.field}}"
/>
);

View file

@ -1,50 +0,0 @@
/**
* Selection node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
export const SelectionNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
return (
<div>
<label>Optionen</label>
{options.map((o, i) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input
placeholder="value"
value={o.value ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], value: e.target.value };
updateParam('options', next);
}}
/>
<input
placeholder="label"
value={o.label ?? ''}
onChange={(e) => {
const next = [...options];
next[i] = { ...next[i], label: e.target.value };
updateParam('options', next);
}}
/>
</div>
))}
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
+ Option
</button>
<div>
<label>
<input
type="checkbox"
checked={(params.multiple as boolean) ?? false}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrfachauswahl
</label>
</div>
</div>
);
};

View file

@ -1,340 +0,0 @@
/**
* SharePoint node config connection selector, paths, search.
* All nodes use SharepointBrowseTree with the selected connection (fetchBrowse + onLoadChildren).
* Folder-style nodes (list, upload target, copy destination): folders only, folder selection.
* File-style nodes (read, download, find path, copy source): file selection; folders expand only.
*/
import React, { useEffect, useState, useCallback } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
const browseDetailsStyle: React.CSSProperties = {
marginTop: 12,
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 6,
background: 'var(--bg-secondary, #f8f9fa)',
overflow: 'hidden',
};
const browseSummaryStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
cursor: 'pointer',
fontWeight: 500,
fontSize: '0.875rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
userSelect: 'none',
};
const browseBodyStyle: React.CSSProperties = {
padding: '0.5rem 0.75rem',
borderTop: '1px solid var(--border-color, #e0e0e0)',
maxHeight: 280,
overflowY: 'auto',
};
function browsePanelTitle(nodeType: string): string {
switch (nodeType) {
case 'sharepoint.uploadFile':
return 'Zielordner durchsuchen';
case 'sharepoint.listFiles':
return 'Ordner durchsuchen';
case 'sharepoint.readFile':
return 'Datei auswählen';
case 'sharepoint.downloadFile':
return 'Datei auswählen';
case 'sharepoint.findFile':
return 'Pfad aus Bibliothek wählen';
default:
return 'SharePoint durchsuchen';
}
}
/** Folder / location pickers — tree shows folders only; selecting sets folder path. */
function isFolderPickerNode(nodeType: string): boolean {
return nodeType === 'sharepoint.uploadFile' || nodeType === 'sharepoint.listFiles';
}
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const [connections, setConnections] = useState<UserConnection[]>([]);
const [browseExpanded, setBrowseExpanded] = useState(false);
const [findFileBrowseExpanded, setFindFileBrowseExpanded] = useState(false);
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
const [connectionsLoading, setConnectionsLoading] = useState(false);
const connectionId = (params.connectionId as string) ?? '';
const path =
(params.path as string) ?? (params.filePath as string) ?? '';
useEffect(() => {
if (instanceId && request) {
setConnectionsLoading(true);
fetchConnections(request, instanceId)
.then(setConnections)
.catch(() => setConnections([]))
.finally(() => setConnectionsLoading(false));
}
}, [instanceId, request]);
const loadChildren = useCallback(
async (pathToLoad: string): Promise<BrowseEntry[]> => {
if (!instanceId || !request || !connectionId) return [];
const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
return r?.items ?? [];
},
[instanceId, request, connectionId]
);
const selectPath = useCallback(
(p: string) => {
updateParam('path', p);
setBrowseExpanded(false);
},
[updateParam]
);
const selectSearchQueryFromFile = useCallback(
(p: string) => {
updateParam('searchQuery', p);
setFindFileBrowseExpanded(false);
},
[updateParam]
);
const selectSourcePath = useCallback(
(p: string) => {
updateParam('sourcePath', p);
setCopySourceExpanded(false);
},
[updateParam]
);
const selectDestPath = useCallback(
(p: string) => {
updateParam('destPath', p);
setCopyDestExpanded(false);
},
[updateParam]
);
const needsSearch = nodeType === 'sharepoint.findFile';
const needsSiteId = false;
const showPathFieldsForList =
nodeType === 'sharepoint.listFiles';
const showPathFieldsForFileUploadDownload =
nodeType === 'sharepoint.readFile' ||
nodeType === 'sharepoint.uploadFile' ||
nodeType === 'sharepoint.downloadFile';
/** Path + browse (same tree wiring) for these types — not copyFile (copy uses its own trees). */
const showStandardPathBrowse =
connectionId &&
(showPathFieldsForList || showPathFieldsForFileUploadDownload);
const showFindFileBrowse = connectionId && needsSearch;
return (
<>
<div>
<label>Connection</label>
<select
value={connectionId}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={connectionsLoading}
>
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{needsSearch && (
<div>
<label>Search query / path</label>
<input
value={(params.searchQuery as string) ?? ''}
onChange={(e) => updateParam('searchQuery', e.target.value)}
placeholder="/sites/SiteName/Shared Documents or search term"
/>
</div>
)}
{showPathFieldsForList && (
<div>
<label>Folder path</label>
<input
value={path}
onChange={(e) => updateParam('path', e.target.value)}
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
/>
</div>
)}
{showPathFieldsForFileUploadDownload && (
<div>
<label>
{nodeType === 'sharepoint.uploadFile'
? 'Target folder path'
: nodeType === 'sharepoint.downloadFile'
? 'File path'
: 'Path'}
</label>
<input
value={(params.path as string) ?? (params.filePath as string) ?? ''}
onChange={(e) => updateParam('path', e.target.value)}
placeholder={
nodeType === 'sharepoint.downloadFile'
? '/sites/SiteName/Shared Documents/file.pdf'
: nodeType === 'sharepoint.uploadFile'
? '/sites/.../Shared Documents/TargetFolder/'
: 'File path'
}
/>
</div>
)}
{needsSiteId && (
<div>
<label>Site ID</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder="SharePoint site ID"
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>Source file</label>
<input
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="/sites/.../folder/file.pdf"
/>
</div>
<div>
<label>Destination folder</label>
<input
value={(params.destPath as string) ?? ''}
onChange={(e) => updateParam('destPath', e.target.value)}
placeholder="/sites/.../target-folder/"
/>
</div>
{connectionId && (
<>
<details
open={copySourceExpanded}
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
<span style={{ opacity: copySourceExpanded ? 0.7 : 1 }}>📂</span>
Quelldatei durchsuchen
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly={false}
onSelectFile={selectSourcePath}
selectedPath={(params.sourcePath as string) || null}
/>
</div>
</details>
<details
open={copyDestExpanded}
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
style={{ ...browseDetailsStyle, marginTop: 8 }}
>
<summary style={{ ...browseSummaryStyle, padding: '0.5rem 0.75rem' }}>
<span style={{ opacity: copyDestExpanded ? 0.7 : 1 }}>📂</span>
Zielordner durchsuchen
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly
onSelectFile={() => {}}
onSelectFolder={selectDestPath}
selectedPath={(params.destPath as string) || null}
/>
</div>
</details>
</>
)}
</>
)}
{showStandardPathBrowse && (
<details
open={browseExpanded}
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={browseSummaryStyle}>
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
{browsePanelTitle(nodeType)}
</summary>
<div style={browseBodyStyle}>
{isFolderPickerNode(nodeType) && (
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
foldersOnly
onSelectFile={() => {}}
onSelectFolder={selectPath}
selectedPath={path || null}
/>
)}
{(nodeType === 'sharepoint.readFile' || nodeType === 'sharepoint.downloadFile') && (
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectPath}
selectedPath={path || null}
/>
)}
</div>
</details>
)}
{showFindFileBrowse && (
<details
open={findFileBrowseExpanded}
onToggle={(e) => setFindFileBrowseExpanded((e.target as HTMLDetailsElement).open)}
style={browseDetailsStyle}
>
<summary style={browseSummaryStyle}>
<span style={{ opacity: findFileBrowseExpanded ? 0.7 : 1 }}>📂</span>
{browsePanelTitle('sharepoint.findFile')}
</summary>
<div style={browseBodyStyle}>
<SharepointBrowseTree
rootPath="/"
onLoadChildren={loadChildren}
onSelectFile={selectSearchQueryFromFile}
selectedPath={(params.searchQuery as string) || null}
/>
</div>
</details>
)}
</>
);
};

View file

@ -1,80 +0,0 @@
/**
* Upload node config allowed file types (multi-select), max size, multiple files.
* Uses shared fileTypeMimeMapping for option definitions.
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { getAcceptValues, parseAllowedTypes } from '../runtime/fileTypeMimeMapping';
import styles from '../../editor/Automation2FlowEditor.module.css';
function buildAcceptString(allowedTypes: string[]): string {
if (allowedTypes.length === 0) return '';
return allowedTypes.join(',');
}
/** Get HTML accept string from node config (for file input). */
export function getAcceptStringFromConfig(config: Record<string, unknown>): string {
const types = parseAllowedTypes(config);
return buildAcceptString(types);
}
const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const allowedTypes = parseAllowedTypes(params);
const maxSize = (params.maxSize as number) ?? 10;
const multiple = (params.multiple as boolean) ?? false;
const toggleType = (value: string) => {
const next = allowedTypes.includes(value)
? allowedTypes.filter((v) => v !== value)
: [...allowedTypes, value];
updateParam('allowedTypes', next);
updateParam('accept', next.length ? buildAcceptString(next) : ''); // legacy compat for backend
};
return (
<div className={styles.uploadNodeConfig}>
<div className={styles.configBlock}>
<label>Erlaubte Dateitypen</label>
<p className={styles.configHint}>
Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
</p>
<div className={styles.fileTypeChips}>
{FILE_TYPE_CHIP_OPTIONS.map((opt) => (
<label key={opt.value} className={styles.fileTypeChip}>
<input
type="checkbox"
checked={allowedTypes.includes(opt.value)}
onChange={() => toggleType(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
</div>
<div className={styles.configBlock}>
<label>Max. Größe (MB)</label>
<input
type="number"
min={0.1}
max={500}
step={1}
value={maxSize}
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 10)}
/>
</div>
<div className={styles.configBlock}>
<label>
<input
type="checkbox"
checked={multiple}
onChange={(e) => updateParam('multiple', e.target.checked)}
/>
Mehrere Dateien erlauben
</label>
</div>
</div>
);
};

View file

@ -1,65 +0,0 @@
/**
* Node config renderers - one per node type (input, ai, email, sharepoint, clickup).
*/
import type { ComponentType } from 'react';
import type { NodeConfigRendererProps } from './types';
import { FormNodeConfig } from '../form/FormNodeConfig';
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
import { UploadNodeConfig } from './UploadNodeConfig';
import { CommentNodeConfig } from './CommentNodeConfig';
import { ReviewNodeConfig } from './ReviewNodeConfig';
import { SelectionNodeConfig } from './SelectionNodeConfig';
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
import { AiNodeConfig } from './AiNodeConfig';
import { EmailNodeConfig } from './EmailNodeConfig';
import { SharePointNodeConfig } from './SharePointNodeConfig';
import { ClickUpNodeConfig } from './ClickUpNodeConfig';
import { StartNodeConfig } from '../start/StartNodeConfig';
import { IfElseNodeConfig } from '../ifElse/IfElseNodeConfig';
import { SwitchNodeConfig } from '../switch/SwitchNodeConfig';
import { LoopNodeConfig } from '../loop/LoopNodeConfig';
import { FormStartNodeConfig } from '../start/FormStartNodeConfig';
import { ScheduleStartNodeConfig } from '../start/ScheduleStartNodeConfig';
import { FileCreateNodeConfig } from './FileCreateNodeConfig';
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
'trigger.manual': StartNodeConfig,
'trigger.form': FormStartNodeConfig,
'trigger.schedule': ScheduleStartNodeConfig,
'input.form': FormNodeConfig,
'input.approval': ApprovalNodeConfig,
'input.upload': UploadNodeConfig,
'input.comment': CommentNodeConfig,
'input.review': ReviewNodeConfig,
'input.selection': SelectionNodeConfig,
'input.confirmation': ConfirmationNodeConfig,
'ai.prompt': AiNodeConfig,
'ai.webResearch': AiNodeConfig,
'ai.summarizeDocument': AiNodeConfig,
'ai.translateDocument': AiNodeConfig,
'ai.convertDocument': AiNodeConfig,
'ai.generateDocument': AiNodeConfig,
'ai.generateCode': AiNodeConfig,
'file.create': FileCreateNodeConfig,
'email.checkEmail': EmailNodeConfig,
'email.searchEmail': EmailNodeConfig,
'email.draftEmail': EmailNodeConfig,
'sharepoint.findFile': SharePointNodeConfig,
'sharepoint.readFile': SharePointNodeConfig,
'sharepoint.uploadFile': SharePointNodeConfig,
'sharepoint.listFiles': SharePointNodeConfig,
'sharepoint.downloadFile': SharePointNodeConfig,
'sharepoint.copyFile': SharePointNodeConfig,
'clickup.searchTasks': ClickUpNodeConfig,
'clickup.listTasks': ClickUpNodeConfig,
'clickup.getTask': ClickUpNodeConfig,
'clickup.createTask': ClickUpNodeConfig,
'clickup.updateTask': ClickUpNodeConfig,
'clickup.uploadAttachment': ClickUpNodeConfig,
'flow.ifElse': IfElseNodeConfig,
'flow.switch': SwitchNodeConfig,
'flow.loop': LoopNodeConfig,
};

View file

@ -1 +0,0 @@
export type { NodeConfigRendererProps, FormField } from '../shared/types';

View file

@ -1,126 +0,0 @@
/**
* Automation2 Flow Editor - Data Picker for selecting node output references.
*/
import React, { useState } from 'react';
import { createRef, type DataRef } from './dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css';
interface DataPickerProps {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef) => void;
availableSourceIds: string[];
nodes: Array<{ id: string; title?: string; type?: string }>;
nodeOutputsPreview: Record<string, unknown>;
getNodeLabel: (node: { id: string; title?: string }) => string;
}
/** Collect all pickable paths (each leads to a value the user can reference) */
function buildPickablePaths(obj: unknown, basePath: (string | number)[] = []): Array<{ path: (string | number)[]; label: string }> {
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : '(ganze Ausgabe)';
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
return [{ path: [...basePath], label: pathLabel }];
}
if (Array.isArray(obj)) {
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
for (let i = 0; i < Math.min(obj.length, 10); i++) {
result.push(...buildPickablePaths(obj[i], [...basePath, i]));
}
return result;
}
if (typeof obj === 'object') {
const result: Array<{ path: (string | number)[]; label: string }> = [{ path: [...basePath], label: pathLabel }];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
result.push(...buildPickablePaths(v, [...basePath, k]));
}
return result;
}
return [{ path: [...basePath], label: pathLabel }];
}
export const DataPicker: React.FC<DataPickerProps> = ({
open,
onClose,
onPick,
availableSourceIds,
nodes,
nodeOutputsPreview,
getNodeLabel,
}) => {
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
if (!open) return null;
const toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
const handlePick = (nodeId: string, path: (string | number)[]) => {
onPick(createRef(nodeId, path));
onClose();
};
return (
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>Datenquelle wählen</h4>
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label="Schließen">
×
</button>
</div>
<div className={styles.dataPickerBody}>
{(() => {
const filteredIds = availableSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual';
});
if (filteredIds.length === 0) {
return <p className={styles.dataPickerEmpty}>Keine vorherigen Nodes verfügbar.</p>;
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
const preview = nodeOutputsPreview[nodeId];
const label = node ? getNodeLabel(node) : nodeId;
const paths = buildPickablePaths(preview);
const isExpanded = expandedNodes.has(nodeId);
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
onClick={() => toggleExpand(nodeId)}
>
<span className={styles.dataPickerExpandIcon}>{isExpanded ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{label}</span>
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.map((p, i) => (
<button
key={`${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(nodeId, p.path)}
>
{p.label}
</button>
))}
</div>
)}
</div>
);
});
})()}
</div>
</div>
</div>
);
};

View file

@ -1,153 +0,0 @@
/**
* Automation2 Flow Editor - Output preview builders per node type.
* Derives example output trees from node parameters for Data Picker.
* Extensible: register builders for new node types without changing core logic.
*/
import type { CanvasNode } from '../../editor/FlowCanvas';
export type OutputPreviewBuilder = (node: CanvasNode) => unknown;
const builders: Record<string, OutputPreviewBuilder> = {};
function parseFormFields(
params: Record<string, unknown>
): Array<{ name: string; type?: string }> {
const raw = params.formFields ?? params.fields;
if (!Array.isArray(raw)) return [];
return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>;
return {
name: String(o.name ?? `field${i + 1}`),
type: typeof o.type === 'string' ? o.type : undefined,
};
}
return { name: `field${i + 1}` };
});
}
function runEnvelopeBase(): Record<string, unknown> {
return {
trigger: { type: 'manual' },
payload: {},
context: {},
files: [],
user: {},
metadata: {},
raw: {},
};
}
/** Register a builder for a node type id (exact match) or prefix (use '*' suffix) */
export function registerOutputPreview(typeIdOrPrefix: string, builder: OutputPreviewBuilder): void {
builders[typeIdOrPrefix] = builder;
}
/** Build preview for a single node; returns {} for unknown types */
export function buildNodeOutputPreview(node: CanvasNode): unknown {
const exact = builders[node.type];
if (exact) return exact(node);
const prefix = node.type.split('.')[0];
const prefixBuilder = builders[`${prefix}.*`];
if (prefixBuilder) return prefixBuilder(node);
return {};
}
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
nodeOutputsFromRun?: Record<string, unknown>
): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const n of nodes) {
const fromRun = nodeOutputsFromRun?.[n.id];
if (fromRun !== undefined) {
result[n.id] = fromRun;
} else {
result[n.id] = buildNodeOutputPreview(n);
}
}
return result;
}
// ---- Built-in builders (extensible, no hardcoding in core) ----
registerOutputPreview('trigger.manual', () => runEnvelopeBase());
registerOutputPreview('trigger.schedule', () => runEnvelopeBase());
registerOutputPreview('trigger.form', (node) => {
const params = node.parameters ?? {};
const fields = parseFormFields(params);
const payload: Record<string, unknown> = {};
for (const f of fields) {
if (f.type === 'clickup_tasks') {
payload[f.name] = { add: ['…'], rem: [] };
} else {
payload[f.name] = '';
}
}
return { ...runEnvelopeBase(), payload };
});
registerOutputPreview('input.form', (node) => {
const params = node.parameters ?? {};
const fields = parseFormFields(params);
const payload: Record<string, unknown> = {};
for (const f of fields) {
if (f.type === 'clickup_tasks') {
payload[f.name] = '';
} else {
payload[f.name] = '';
}
}
/** Nur payload — kein Spread der Keys nach oben (vermeidet doppelte / verwirrende Pfade im Data Picker). */
return { payload };
});
registerOutputPreview('input.upload', () => ({
file: { id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' },
files: [{ id: '...', fileName: 'doc.pdf', mimeType: 'application/pdf' }],
fileIds: ['...'],
}));
registerOutputPreview('ai.*', () => ({ prompt: '...', context: '...', result: '...' }));
registerOutputPreview('file.*', () => ({
documents: [{ documentName: '...', documentData: '...' }],
documentList: [{ documentName: '...', documentData: '...' }],
}));
registerOutputPreview('email.*', () => ({ subject: '...', body: '...' }));
registerOutputPreview('email.searchEmail', () => ({
data: { searchResults: { results: [{ subject: '...', from: '...' }] } },
}));
registerOutputPreview('email.checkEmail', () => ({
data: { emails: { emails: [{ subject: '...', from: '...' }] } },
}));
registerOutputPreview('sharepoint.*', () => ({ file: { url: '...', name: '...' } }));
registerOutputPreview('sharepoint.listFiles', () => ({
files: [{ url: '...', name: '...' }],
}));
registerOutputPreview('clickup.createTask', () => ({
success: true,
taskId: '…',
clickupTask: { id: '…', name: '…' },
documents: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
documentList: [{ documentName: 'clickup_create_task.json', documentData: '{}' }],
}));
registerOutputPreview('clickup.*', () => ({
success: true,
taskId: '…',
clickupTask: { id: '…' },
documents: [{ documentName: 'clickup_result.json', documentData: '{}' }],
}));
registerOutputPreview('flow.ifElse', () => ({ branch: 0, conditionResult: true, input: {} }));
registerOutputPreview('flow.switch', () => ({ match: 0, value: '...' }));
registerOutputPreview('flow.loop', () => ({
items: [],
count: 0,
currentItem: { name: 'field', value: '...' },
currentIndex: 0,
}));

View file

@ -1,946 +0,0 @@
/**
* AutomationEditor Styles
*
* Full-screen editor with form on left and actions panel on right
*/
/* Used when AutomationEditor had custom overlay - kept for reference, Popup is used now */
.editorOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
/* Popup customisation for fullscreen editor - fill content area */
.editorPopup :global([class*="content"]) {
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.editorContainer {
background: var(--surface-color, #ffffff);
border-radius: 12px;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.editorHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
flex-shrink: 0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 1rem;
}
.editorTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #333);
margin: 0;
}
.modeBadge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.modeBadge.template {
background: var(--info-bg, #e3f2fd);
color: var(--info-color, #1976d2);
}
.modeBadge.definition {
background: var(--success-bg, #e8f5e9);
color: var(--success-color, #388e3c);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 1.125rem;
transition: all 0.2s;
}
.closeButton:hover {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
}
/* Main Content Area */
.editorContent {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Form Panel (Left Side) */
.formPanel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border-color, #e0e0e0);
}
.formPanelHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary, #f5f5f5);
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.formPanelTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #666);
margin: 0;
}
.formPanelContent {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
/* Actions Panel (Right Side) */
.actionsPanel {
width: 400px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #f8f9fa);
}
.actionsPanelCollapsed {
width: 48px;
}
.actionsPanelToggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
flex-shrink: 0;
}
.actionsPanelToggle:hover {
background: var(--bg-hover, #e8e8e8);
color: var(--text-primary, #333);
}
.actionsPanelToggle svg {
margin-right: 0.5rem;
}
.actionsPanelCollapsed .actionsPanelToggle {
writing-mode: vertical-rl;
text-orientation: mixed;
padding: 1rem 0.75rem;
height: 100%;
}
.actionsPanelCollapsed .actionsPanelToggle svg {
margin-right: 0;
margin-bottom: 0.5rem;
transform: rotate(90deg);
}
.actionsPanelContainer {
flex: 1;
overflow: hidden;
}
/* Footer */
.editorFooter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
flex-shrink: 0;
}
.footerLeft {
display: flex;
align-items: center;
gap: 1rem;
}
.footerRight {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Buttons */
.primaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 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, transform 0.1s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.primaryButton:active:not(:disabled) {
transform: scale(0.98);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.secondaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--surface-color, #ffffff);
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.secondaryButton:hover:not(:disabled) {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--text-secondary, #666);
}
.secondaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dangerButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--danger-color, #dc3545);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.dangerButton:hover:not(:disabled) {
background: var(--danger-dark, #c82333);
}
/* JSON Editor Section */
.jsonEditorSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.jsonEditorHeader {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.jsonEditorLabelRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.jsonEditorLabel {
display: flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.jsonEditorHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.formatButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.formatButton:hover {
background: var(--primary-color, #f25843);
color: white;
border-color: var(--primary-color, #f25843);
}
.jsonTextarea {
width: 100%;
min-height: 300px;
padding: 1rem;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 0.8125rem;
line-height: 1.5;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-code, #1e1e1e);
color: var(--text-code, #d4d4d4);
resize: vertical;
tab-size: 2;
}
.jsonTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.jsonTextarea.error {
border-color: var(--danger-color, #dc3545);
}
.jsonError {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--danger-bg, #fef2f2);
color: var(--danger-color, #dc3545);
border-radius: 4px;
font-size: 0.8125rem;
}
/* Placeholders Section */
.placeholdersSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.placeholdersHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.placeholdersTitle {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.placeholdersHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.placeholdersList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.placeholderItem {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
.placeholderKeyRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.placeholderKey {
padding: 0.375rem 0.625rem;
background: var(--bg-code, #e9ecef);
border-radius: 4px;
font-family: monospace;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.placeholderDescription {
font-size: 0.75rem;
color: var(--text-secondary, #666);
flex: 1;
}
.placeholderType {
padding: 0.25rem 0.5rem;
background: var(--info-bg, #e3f2fd);
color: var(--info-color, #1976d2);
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.placeholderError {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--error-bg, #ffebee);
color: var(--error-color, #c62828);
border: 1px solid var(--error-border, #ef9a9a);
border-radius: 6px;
font-size: 0.8125rem;
}
.placeholderError svg {
flex-shrink: 0;
}
.sharepointFolderInput {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sharepointFolderHint {
font-size: 0.75rem;
color: var(--text-secondary, #666);
font-style: italic;
}
/* SharePoint Folder Picker */
.sharepointFolderPicker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sharepointFolderHeader {
display: flex;
gap: 0.5rem;
align-items: center;
}
.sharepointFolderHeader .placeholderInput {
flex: 1;
}
.sharepointBrowseButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--secondary-button-bg, #f0f0f0);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #333);
white-space: nowrap;
transition: background-color 0.15s;
}
.sharepointBrowseButton:hover {
background: var(--secondary-button-hover-bg, #e0e0e0);
}
.sharepointFolderBrowser {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
background: var(--bg-secondary, #fafafa);
}
.sharepointError {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--danger-bg, #fff0f0);
color: var(--danger-color, #d32f2f);
border-radius: 4px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.sharepointSection {
margin-bottom: 1rem;
}
.sharepointSection:last-child {
margin-bottom: 0;
}
.sharepointSection label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.sharepointLoading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sharepointSelect {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, white);
cursor: pointer;
}
.sharepointSelect:focus {
outline: none;
border-color: var(--primary-color, #1976d2);
}
.sharepointBreadcrumb {
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.sharepointFolderList {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-primary, white);
}
.sharepointFolderItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
border-bottom: 1px solid var(--border-light, #f0f0f0);
transition: background-color 0.1s;
}
.sharepointFolderItem:last-child {
border-bottom: none;
}
.sharepointFolderItem:hover {
background: var(--bg-hover, #f5f5f5);
}
.sharepointFolderItem .folderName {
flex: 1;
cursor: pointer;
}
.sharepointFolderItem .folderName:hover {
text-decoration: underline;
}
.selectFolderButton {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: var(--primary-color, #1976d2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.sharepointFolderItem:hover .selectFolderButton {
opacity: 1;
}
.selectFolderButton:hover {
background: var(--primary-hover, #1565c0);
}
.sharepointEmpty {
padding: 1rem;
text-align: center;
color: var(--text-secondary, #666);
font-size: 0.875rem;
font-style: italic;
}
.selectCurrentFolderButton {
width: 100%;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--success-color, #2e7d32);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.15s;
}
.selectCurrentFolderButton:hover {
background: var(--success-hover, #1b5e20);
}
.placeholderInput {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
}
.placeholderInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderSelect {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
cursor: pointer;
}
.placeholderSelect:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderSelect:disabled {
background: var(--bg-secondary, #f5f5f5);
cursor: not-allowed;
}
.placeholderTextarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
resize: vertical;
min-height: 60px;
}
.placeholderTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderCheckbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-primary, #333);
cursor: pointer;
}
.placeholderCheckbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary-color, #f25843);
cursor: pointer;
}
.noPlaceholders {
padding: 1rem;
text-align: center;
color: var(--text-tertiary, #999);
font-size: 0.875rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
/* Form Fields */
.formFields {
display: flex;
flex-direction: column;
gap: 1rem;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.formLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.formLabel .required {
color: var(--danger-color, #dc3545);
}
.formInput {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
transition: border-color 0.2s, box-shadow 0.2s;
}
.formInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.formTextarea {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
resize: vertical;
min-height: 80px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.formTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.formHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
margin: 0;
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
cursor: pointer;
}
.checkboxLabel input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary-color, #f25843);
cursor: pointer;
}
/* Language Tabs */
.languageTabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
padding-bottom: 0.5rem;
}
.languageTab {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
cursor: pointer;
transition: all 0.2s;
}
.languageTab:hover {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
}
.languageTab.active {
background: var(--bg-primary, #ffffff);
border-color: var(--border-color, #e0e0e0);
color: var(--primary-color, #f25843);
border-bottom: 2px solid var(--primary-color, #f25843);
margin-bottom: -1px;
}
/* Loading State */
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary, #666);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 1200px) {
.actionsPanel {
width: 350px;
}
}
@media (max-width: 900px) {
.editorContent {
flex-direction: column;
}
.formPanel {
border-right: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.actionsPanel {
width: 100%;
height: 300px;
}
.actionsPanelCollapsed {
width: 100%;
height: 48px;
}
.actionsPanelCollapsed .actionsPanelToggle {
writing-mode: horizontal-tb;
text-orientation: mixed;
padding: 0.75rem;
height: auto;
}
.actionsPanelCollapsed .actionsPanelToggle svg {
margin-bottom: 0;
margin-right: 0.5rem;
transform: none;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
export { AutomationEditor, type AutomationEditorProps, type EditorMode } from './AutomationEditor';
export { default } from './AutomationEditor';

View file

@ -0,0 +1,105 @@
/**
* ChatInput -- Shared chat input component.
*
* Simple text input with send button, usable by both Workspace and Editor.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { useLanguage } from '../../providers/language/LanguageContext';
interface ChatInputProps {
onSend: (message: string) => void;
isProcessing?: boolean;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
style?: React.CSSProperties;
}
export const ChatInput: React.FC<ChatInputProps> = ({
onSend,
isProcessing,
placeholder,
disabled,
autoFocus = true,
style,
}) => {
const { t } = useLanguage();
const resolvedPlaceholder = placeholder ?? t('Nachricht eingeben…');
const [value, setValue] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const _handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isProcessing || disabled) return;
onSend(trimmed);
setValue('');
}, [value, isProcessing, disabled, onSend]);
const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_handleSend();
}
},
[_handleSend]
);
return (
<div
style={{
display: 'flex',
gap: '8px',
padding: '8px 12px',
borderTop: '1px solid var(--border-color, #e0e0e0)',
alignItems: 'flex-end',
...style,
}}
>
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={resolvedPlaceholder}
disabled={isProcessing || disabled}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-color, #ddd)',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '13px',
fontFamily: 'inherit',
outline: 'none',
minHeight: '36px',
maxHeight: '120px',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-primary, #333)',
}}
/>
<button
onClick={_handleSend}
disabled={!value.trim() || isProcessing || disabled}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: !value.trim() || isProcessing || disabled ? '#ccc' : 'var(--color-primary, #2563eb)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: !value.trim() || isProcessing || disabled ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>
{isProcessing ? '…' : t('Senden')}
</button>
</div>
);
};

View file

@ -0,0 +1,92 @@
/**
* ChatMessageList -- Shared chat message display component.
*
* Renders a scrollable list of messages with Markdown support.
* Used by both the Workspace ChatStream and the Editor ChatPanel.
*/
import React, { useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: number;
}
interface ChatMessageListProps {
messages: ChatMessage[];
isProcessing?: boolean;
emptyMessage?: string;
style?: React.CSSProperties;
}
const _roleColors: Record<string, string> = {
user: 'var(--color-primary, #2563eb)',
assistant: 'var(--text-primary, #333)',
system: 'var(--text-secondary, #888)',
};
export const ChatMessageList: React.FC<ChatMessageListProps> = ({
messages,
isProcessing,
emptyMessage,
style,
}) => {
const { t } = useLanguage();
const resolvedEmpty = emptyMessage ?? t('Noch keine Nachrichten.');
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
...style,
}}
>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px', textAlign: 'center', marginTop: '24px' }}>
{resolvedEmpty}
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
style={{
padding: '8px 12px',
borderRadius: '8px',
background: msg.role === 'user' ? 'var(--bg-secondary, #f5f5f5)' : 'transparent',
fontSize: '13px',
lineHeight: 1.5,
color: _roleColors[msg.role] || 'var(--text-primary, #333)',
}}
>
<div style={{ fontWeight: 600, fontSize: '11px', marginBottom: '4px', textTransform: 'uppercase', color: 'var(--text-secondary, #888)' }}>
{msg.role}
</div>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
))}
{isProcessing && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '12px', fontStyle: 'italic' }}>
{t('Wird verarbeitet…')}
</div>
)}
<div ref={bottomRef} />
</div>
);
};

View file

@ -0,0 +1,3 @@
export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList';
export { ChatInput } from './ChatInput';

View file

@ -61,11 +61,11 @@ export function ContentPreview({
if (isOpen && fileId) { if (isOpen && fileId) {
// Check if we have valid data // Check if we have valid data
if (!fileId || fileId === 'undefined' || fileId === 'null') { if (!fileId || fileId === 'undefined' || fileId === 'null') {
setError('Invalid file ID'); setError(t('Ungültige Datei-ID'));
return; return;
} }
if (!fileName || fileName === 'Unknown Item') { if (!fileName || fileName === 'Unknown Item' || fileName === 'Unbekanntes Element') {
setError('File name not available'); setError(t('Dateiname nicht verfügbar'));
return; return;
} }
loadPreview(); loadPreview();
@ -77,7 +77,7 @@ export function ContentPreview({
} }
setError(null); setError(null);
} }
}, [isOpen, fileId, fileName]); }, [isOpen, fileId, fileName, t]);
const loadPreview = async () => { const loadPreview = async () => {
@ -95,10 +95,10 @@ export function ContentPreview({
} }
// If it's text content but MIME type says PDF, we'll handle it in renderPreview // If it's text content but MIME type says PDF, we'll handle it in renderPreview
} else { } else {
setError(result.error || 'Failed to load preview'); setError(result.error || t('Vorschau konnte nicht geladen werden.'));
} }
} catch (err) { } catch (err) {
setError('An unexpected error occurred while loading the preview'); setError(t('Ein unerwarteter Fehler ist aufgetreten, während'));
} }
}; };
@ -141,7 +141,7 @@ export function ContentPreview({
const actions: PopupAction[] = [ const actions: PopupAction[] = [
// Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs // Copy Content Button - only show for text-based files (exclude PDFs and images) or corrupted PDFs
...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{ ...(mimeType !== 'application/pdf' && !mimeType?.startsWith('image/') && (mimeType?.startsWith('text/') || mimeType === 'application/json' || previewContent) ? [{
label: copySuccess ? t('files.preview.copied', 'Copied!') : t(''), label: copySuccess ? t('In die Zwischenablage kopiert') : t(''),
icon: copySuccess ? '✓' : <IoIosCopy />, icon: copySuccess ? '✓' : <IoIosCopy />,
onClick: handleCopyContent, onClick: handleCopyContent,
disabled: !previewContent && !previewUrl, disabled: !previewContent && !previewUrl,
@ -168,7 +168,7 @@ export function ContentPreview({
previewUrl={undefined} previewUrl={undefined}
previewContent={previewContent} previewContent={previewContent}
fileName={fileName} fileName={fileName}
onError={() => setError('Failed to load PDF preview')} onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
/> />
); );
} }
@ -194,14 +194,14 @@ export function ContentPreview({
return ( return (
<div className={styles.jsonContainer}> <div className={styles.jsonContainer}>
<div className={styles.jsonHeader}> <div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>JSON Preview (Fallback)</span> <span className={styles.jsonTitle}>{t('JSON-Vorschau als Fallback')}</span>
<div className={styles.jsonHeaderRight}> <div className={styles.jsonHeaderRight}>
<span className={styles.jsonSize}>Raw content</span> <span className={styles.jsonSize}>{t('Rohinhalt')}</span>
</div> </div>
</div> </div>
<pre className={styles.jsonPreview}> <pre className={styles.jsonPreview}>
<code className={styles.jsonCode}> <code className={styles.jsonCode}>
{previewContent || 'No content available'} {previewContent || t('Kein Inhalt verfügbar')}
</code> </code>
</pre> </pre>
</div> </div>
@ -219,7 +219,7 @@ export function ContentPreview({
<ImageRenderer <ImageRenderer
previewUrl={previewUrl} previewUrl={previewUrl}
fileName={fileName} fileName={fileName}
onError={() => setError('Failed to load image preview')} onError={() => setError(t('Bildvorschau konnte nicht geladen werden'))}
/> />
); );
@ -230,7 +230,7 @@ export function ContentPreview({
<HtmlRenderer <HtmlRenderer
previewUrl={previewUrl} previewUrl={previewUrl}
fileName={fileName} fileName={fileName}
onError={() => setError('Failed to load HTML preview')} onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
/> />
); );
} }
@ -240,7 +240,7 @@ export function ContentPreview({
previewUrl={previewUrl} previewUrl={previewUrl}
fileName={fileName} fileName={fileName}
mimeType={mimeType} mimeType={mimeType}
onError={() => setError('Failed to load text preview')} onError={() => setError(t('Textvorschau konnte nicht geladen werden'))}
/> />
); );
@ -257,7 +257,7 @@ export function ContentPreview({
previewUrl={previewUrl} previewUrl={previewUrl}
previewContent={previewContent || undefined} previewContent={previewContent || undefined}
fileName={fileName} fileName={fileName}
onError={() => setError('Failed to load PDF preview')} onError={() => setError(t('PDF-Vorschau konnte nicht geladen werden'))}
/> />
); );
} }
@ -267,7 +267,7 @@ export function ContentPreview({
<HtmlRenderer <HtmlRenderer
previewUrl={previewUrl} previewUrl={previewUrl}
fileName={fileName} fileName={fileName}
onError={() => setError('Failed to load HTML preview')} onError={() => setError(t('HTML-Vorschau konnte nicht geladen werden'))}
/> />
); );
} }
@ -279,7 +279,7 @@ export function ContentPreview({
previewUrl={previewUrl} previewUrl={previewUrl}
fileName={fileName} fileName={fileName}
mimeType={mimeType} mimeType={mimeType}
onError={() => setError('Preview not supported for this file type')} onError={() => setError(t('Vorschau wird für dieses Format nicht unterstützt'))}
/> />
); );
@ -292,7 +292,7 @@ export function ContentPreview({
<Popup <Popup
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`} title={`${t('Dateivorschau')}: ${fileName}`}
size="fullscreen" size="fullscreen"
className={styles.contentPreviewPopup} className={styles.contentPreviewPopup}
actions={actions} actions={actions}

View file

@ -79,7 +79,7 @@ export function UrlContentPreview({
} }
// If PDF.js also fails, show error // If PDF.js also fails, show error
setIsLoading(false); setIsLoading(false);
setError('Failed to load PDF. This might be due to CORS restrictions. You can try downloading the file or opening it in a new tab.'); setError(t('Fehler beim Laden des PDFs'));
setShowPdfAnyway(true); setShowPdfAnyway(true);
}; };
@ -96,7 +96,9 @@ export function UrlContentPreview({
const warningTimeout = setTimeout(() => { const warningTimeout = setTimeout(() => {
if (isLoading && !hasLoaded) { if (isLoading && !hasLoaded) {
setWarning('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.'); setWarning(
t('PDF lädt langsam. Sie können es auch direkt herunterladen oder in einem neuen Tab öffnen.')
);
// Don't set isLoading to false - let it continue // Don't set isLoading to false - let it continue
} }
}, WARNING_TIMEOUT); }, WARNING_TIMEOUT);
@ -107,11 +109,11 @@ export function UrlContentPreview({
console.log('PDF loading timeout, switching to PDF.js fallback'); console.log('PDF loading timeout, switching to PDF.js fallback');
setUsePdfJs(true); setUsePdfJs(true);
setIsLoading(true); // Restart loading with PDF.js setIsLoading(true); // Restart loading with PDF.js
setWarning('PDF lädt langsam. Versuche alternative Anzeigemethode...'); setWarning(t('PDF lädt langsam. Alternative Anzeigemethode wird versucht…'));
} else if (isLoading && !hasLoaded && usePdfJs) { } else if (isLoading && !hasLoaded && usePdfJs) {
// PDF.js also failed, show error // PDF.js also failed, show error
setShowPdfAnyway(true); setShowPdfAnyway(true);
setError('PDF lädt langsam. Bitte verwenden Sie den Download-Button oder öffnen Sie es in einem neuen Tab.'); setError(t('PDF lädt langsam, bitte verwenden'));
setIsLoading(false); setIsLoading(false);
} }
}, QUICK_TIMEOUT); }, QUICK_TIMEOUT);
@ -121,7 +123,7 @@ export function UrlContentPreview({
clearTimeout(errorTimeout); clearTimeout(errorTimeout);
}; };
} }
}, [isOpen, isLoading, hasLoaded, usePdfJs]); }, [isOpen, isLoading, hasLoaded, usePdfJs, t]);
// Validate URL // Validate URL
useEffect(() => { useEffect(() => {
@ -129,7 +131,7 @@ export function UrlContentPreview({
try { try {
new URL(url); new URL(url);
} catch (e) { } catch (e) {
setError('Invalid URL'); setError(t('Ungültige URL'));
setIsLoading(false); setIsLoading(false);
} }
} }
@ -184,7 +186,7 @@ export function UrlContentPreview({
padding: '0.5rem 1rem' padding: '0.5rem 1rem'
}} }}
> >
In neuem Tab öffnen {t('In neuem Tab öffnen')}
</button> </button>
<button <button
onClick={handleDownload} onClick={handleDownload}
@ -195,7 +197,7 @@ export function UrlContentPreview({
padding: '0.5rem 1rem' padding: '0.5rem 1rem'
}} }}
> >
Download {t('Herunterladen')}
</button> </button>
</div> </div>
</div> </div>
@ -229,7 +231,7 @@ export function UrlContentPreview({
}} }}
className={styles.retryButton} className={styles.retryButton}
> >
{t('common.retry', 'Retry')} {t('Wiederholen')}
</button> </button>
<button <button
onClick={handleOpenInNewTab} onClick={handleOpenInNewTab}
@ -241,7 +243,7 @@ export function UrlContentPreview({
fontWeight: '500' fontWeight: '500'
}} }}
> >
In neuem Tab öffnen {t('In neuem Tab öffnen')}
</button> </button>
<button <button
onClick={handleDownload} onClick={handleDownload}
@ -253,7 +255,7 @@ export function UrlContentPreview({
fontWeight: '500' fontWeight: '500'
}} }}
> >
Download File {t('Datei herunterladen')}
</button> </button>
</div> </div>
</div> </div>
@ -284,7 +286,7 @@ export function UrlContentPreview({
fontWeight: '500' fontWeight: '500'
}} }}
> >
In neuem Tab öffnen {t('In neuem Tab öffnen')}
</button> </button>
<button <button
onClick={handleDownload} onClick={handleDownload}
@ -296,7 +298,7 @@ export function UrlContentPreview({
fontWeight: '500' fontWeight: '500'
}} }}
> >
Download {t('Herunterladen')}
</button> </button>
</div> </div>
</div> </div>
@ -314,9 +316,9 @@ export function UrlContentPreview({
<div className={styles.unsupportedContainer}> <div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div> <div className={styles.unsupportedIcon}>📄</div>
<div className={styles.fileName}>{fileName}</div> <div className={styles.fileName}>{fileName}</div>
<p>Preview not supported for this file type. Please download the file to view it.</p> <p>{t('Vorschau wird hierfür nicht unterstützt')}</p>
<button onClick={handleDownload} className={styles.retryButton}> <button onClick={handleDownload} className={styles.retryButton}>
Download File {t('Datei herunterladen')}
</button> </button>
</div> </div>
); );
@ -329,7 +331,7 @@ export function UrlContentPreview({
<Popup <Popup
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
title={`${t('files.preview.title', 'Content Preview')}: ${fileName}`} title={`${t('Dateivorschau')}: ${fileName}`}
size="fullscreen" size="fullscreen"
className={styles.contentPreviewPopup} className={styles.contentPreviewPopup}
actions={actions} actions={actions}

View file

@ -17,7 +17,7 @@ export function ErrorRenderer({ error, onRetry }: ErrorRendererProps) {
onClick={onRetry} onClick={onRetry}
className={styles.retryButton} className={styles.retryButton}
> >
{t('common.retry', 'Retry')} {t('Wiederholen')}
</button> </button>
</div> </div>
); );

View file

@ -303,7 +303,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
<button <button
className={styles.collapseButton} className={styles.collapseButton}
onClick={() => toggleCollapse(rowPath)} onClick={() => toggleCollapse(rowPath)}
title={isCollapsed ? 'Expand' : 'Collapse'} title={isCollapsed ? t('Aufklappen') : t('Einklappen')}
> >
{isCollapsed ? '▶' : '▼'} {isCollapsed ? '▶' : '▼'}
</button> </button>
@ -339,7 +339,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
) : ( ) : (
typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ? typeof data.values[index] === 'object' && data.values[index] !== null && 'keys' in data.values[index] ?
renderTable(data.values[index], level + 1, rowPath) : renderTable(data.values[index], level + 1, rowPath) :
<span className={styles.jsonValue}>Error: Invalid nested data</span> <span className={styles.jsonValue}>{t('Fehler: Ungültige verschachtelte Daten')}</span>
) )
)} )}
</div> </div>
@ -471,7 +471,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
<div className={styles.jsonContainer}> <div className={styles.jsonContainer}>
<div className={styles.jsonHeader}> <div className={styles.jsonHeader}>
<div className={styles.jsonHeaderRight}> <div className={styles.jsonHeaderRight}>
<span className={styles.jsonSize}>{preprocessedData.keys.length} {t('files.preview.json.properties', 'properties')}</span> <span className={styles.jsonSize}>{preprocessedData.keys.length} {t('Eigenschaften')}</span>
</div> </div>
</div> </div>
{renderTable(preprocessedData, 0, 'root')} {renderTable(preprocessedData, 0, 'root')}
@ -479,7 +479,7 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
); );
} catch (parseError) { } catch (parseError) {
const rawData = { const rawData = {
keys: ['Raw Content'], keys: [t('Rohinhalt')],
values: [previewContent], values: [previewContent],
types: ['string'], types: ['string'],
isNested: [false] isNested: [false]
@ -488,14 +488,14 @@ export function JsonRenderer({ previewContent, fileName }: JsonRendererProps) {
return ( return (
<div className={styles.jsonContainer}> <div className={styles.jsonContainer}>
<div className={styles.jsonHeader}> <div className={styles.jsonHeader}>
<span className={styles.jsonTitle}>{t('files.preview.json.invalid', 'Raw Content (Invalid JSON)')}: {fileName}</span> <span className={styles.jsonTitle}>{t('Ungültiges JSON')}: {fileName}</span>
<div className={styles.jsonHeaderRight}> <div className={styles.jsonHeaderRight}>
<button <button
className={styles.copyButton} className={styles.copyButton}
onClick={handleCopyJson} onClick={handleCopyJson}
title={t('files.preview.json.copyRaw', 'Copy content to clipboard')} title={t('Rohtext in die Zwischenablage kopieren')}
> >
📋 {t('common.copy', 'Copy')} 📋 {t('Kopieren')}
</button> </button>
</div> </div>
</div> </div>

View file

@ -7,7 +7,7 @@ export function LoadingRenderer() {
return ( return (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner}></div> <div className={styles.spinner}></div>
<p>{t('files.preview.loading', 'Loading preview...')}</p> <p>{t('Vorschau wird geladen...')}</p>
</div> </div>
); );
} }

View file

@ -3,6 +3,8 @@ import { useEffect, useRef, useState } from 'react';
import * as pdfjsLib from 'pdfjs-dist'; import * as pdfjsLib from 'pdfjs-dist';
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Set worker source for PDF.js // Set worker source for PDF.js
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// Try to use local worker first, fallback to CDN // Try to use local worker first, fallback to CDN
@ -24,7 +26,9 @@ interface PdfJsRendererProps {
onLoad?: () => void; onLoad?: () => void;
} }
export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) { export function PdfJsRenderer({
previewUrl, fileName: _fileName, onError, onLoad }: PdfJsRendererProps) {
const { t } = useLanguage();
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@ -62,7 +66,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
} catch (err) { } catch (err) {
console.error('Error loading PDF with PDF.js:', err); console.error('Error loading PDF with PDF.js:', err);
if (isMounted) { if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to load PDF'); setError(err instanceof Error ? err.message : t('PDF konnte nicht geladen werden.'));
setIsLoading(false); setIsLoading(false);
onError(); onError();
} }
@ -112,7 +116,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
} catch (err) { } catch (err) {
console.error('Error rendering PDF page:', err); console.error('Error rendering PDF page:', err);
if (isMounted) { if (isMounted) {
setError(err instanceof Error ? err.message : 'Failed to render PDF page'); setError(err instanceof Error ? err.message : t('PDF-Seite konnte nicht gerendert werden.'));
} }
} }
}; };
@ -128,7 +132,9 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
return ( return (
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
<div className={styles.errorIcon}></div> <div className={styles.errorIcon}></div>
<p>Fehler beim Laden der PDF: {error}</p> <p>
{t('Fehler beim Laden der PDF:')} {error}
</p>
</div> </div>
); );
} }
@ -137,7 +143,7 @@ export function PdfJsRenderer({ previewUrl, fileName: _fileName, onError, onLoad
return ( return (
<div className={styles.loadingContainer}> <div className={styles.loadingContainer}>
<div className={styles.spinner}></div> <div className={styles.spinner}></div>
<p>PDF wird geladen...</p> <p>{t('PDF wird geladen')}</p>
</div> </div>
); );
} }

View file

@ -31,7 +31,7 @@ export function PdfRenderer({ previewUrl, previewContent, fileName, onError }: P
<div className={styles.warningMessage}> <div className={styles.warningMessage}>
<span className={styles.warningIcon}><IoIosWarning /></span> <span className={styles.warningIcon}><IoIosWarning /></span>
<span className={styles.warningText}> <span className={styles.warningText}>
{t('files.preview.pdfFileCorrupted', 'This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible.')} {t('Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.')}
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,5 +1,7 @@
import styles from '../ContentPreview.module.css'; import styles from '../ContentPreview.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
// Updated to handle both previewUrl and previewContent // Updated to handle both previewUrl and previewContent
interface TextRendererProps { interface TextRendererProps {
@ -10,13 +12,15 @@ interface TextRendererProps {
onError: () => void; onError: () => void;
} }
export function TextRenderer({ previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) { export function TextRenderer({
previewUrl, previewContent, fileName, mimeType, onError }: TextRendererProps) {
const { t } = useLanguage();
// If we have previewContent directly, display it as text // If we have previewContent directly, display it as text
if (previewContent && !previewUrl) { if (previewContent && !previewUrl) {
return ( return (
<div className={styles.textContainer}> <div className={styles.textContainer}>
<div className={styles.textHeader}> <div className={styles.textHeader}>
<span className={styles.textTitle}>Text Preview</span> <span className={styles.textTitle}>{t('Textvorschau')}</span>
</div> </div>
<pre className={styles.textPreview}> <pre className={styles.textPreview}>
<code className={styles.textCode}> <code className={styles.textCode}>

View file

@ -12,14 +12,14 @@ export function UnsupportedRenderer({ previewUrl, fileName }: UnsupportedRendere
return ( return (
<div className={styles.unsupportedContainer}> <div className={styles.unsupportedContainer}>
<div className={styles.unsupportedIcon}>📄</div> <div className={styles.unsupportedIcon}>📄</div>
<p>{t('files.preview.unsupported', 'Preview not available for this file type')}</p> <p>{t('Vorschau für diesen Dateityp nicht verfügbar')}</p>
<p className={styles.fileName}>{fileName}</p> <p className={styles.fileName}>{fileName}</p>
<a <a
href={previewUrl} href={previewUrl}
download={fileName} download={fileName}
className={styles.downloadButton} className={styles.downloadButton}
> >
{t('files.action.download', 'Download')} {t('Herunterladen')}
</a> </a>
</div> </div>
); );

View file

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

View file

@ -14,13 +14,29 @@
SIDEBAR - Node List SIDEBAR - Node List
============================================================================= */ ============================================================================= */
.sidebar { .resizeDivider {
flex-shrink: 0; flex-shrink: 0;
width: 5px;
cursor: col-resize;
background: var(--border-color, #e0e0e0);
transition: background 0.15s;
position: relative;
z-index: 5;
}
.resizeDivider:hover,
.resizeDivider:active {
background: var(--primary-color, #007bff);
}
.sidebar {
flex: 1;
min-height: 0;
width: 280px; width: 280px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--bg-secondary, #f8f9fa); background: var(--bg-secondary, #f8f9fa);
border-right: 1px solid var(--border-color, #e0e0e0); border-left: none;
overflow: hidden; overflow: hidden;
} }
@ -108,6 +124,7 @@
cursor: grab; cursor: grab;
transition: background 0.15s; transition: background 0.15s;
border: 1px solid transparent; border: 1px solid transparent;
position: relative;
} }
.nodeItem:hover { .nodeItem:hover {
@ -151,6 +168,29 @@
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.nodeItem .nodeItemTooltip {
display: none;
position: absolute;
left: 0;
top: 100%;
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
color: var(--text-primary, #333);
white-space: normal;
word-break: break-word;
max-width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.nodeItem:hover .nodeItemTooltip {
display: block;
}
/* Loading / Error */ /* Loading / Error */
.loading, .loading,
.error { .error {
@ -318,6 +358,19 @@
box-shadow: 0 0 0 2px var(--primary-color, #007bff); box-shadow: 0 0 0 2px var(--primary-color, #007bff);
} }
.canvasNodeHighlighted {
transition: border-color 0.3s ease, background-color 0.3s ease, box-shadow 0.3s ease;
}
@keyframes pulseGlow {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.canvasNodeHighlighted[style*="box-shadow"] {
animation: pulseGlow 1.5s ease-in-out infinite;
}
.canvasNodeContent { .canvasNodeContent {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -360,6 +413,39 @@
text-decoration: underline; text-decoration: underline;
} }
.canvasNodeComment {
font-size: 0.7rem;
color: var(--text-tertiary, #999);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.2;
}
.canvasNode .canvasNodeCommentTooltip {
display: none;
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: calc(100% + 6px);
z-index: 100;
background: var(--bg-primary, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font-size: 0.75rem;
color: var(--text-primary, #333);
white-space: normal;
word-break: break-word;
max-width: 260px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
pointer-events: none;
}
.canvasNode:hover .canvasNodeCommentTooltip {
display: block;
}
.canvasNodeInput { .canvasNodeInput {
width: 100%; width: 100%;
padding: 0.15rem 0.25rem; padding: 0.15rem 0.25rem;
@ -446,6 +532,13 @@
color: var(--text-tertiary, #999); color: var(--text-tertiary, #999);
} }
.nodeConfigDescription {
margin: -0.5rem 0 0.75rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
line-height: 1.4;
}
.nodeConfigPanel label { .nodeConfigPanel label {
display: block; display: block;
font-size: 0.75rem; font-size: 0.75rem;
@ -713,13 +806,13 @@
.scheduleModeBlock { .scheduleModeBlock {
position: relative; position: relative;
/* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */ /* Ausgewählte Karte (orange) + Text auf „An“-Chips im erweiterten Bereich */
--schedule-active: var(--schedule-mode-active, var(--color-secondary, #f25843)); --schedule-active: var(--schedule-mode-active, var(--color-secondary));
--schedule-active-border: var(--schedule-mode-active-border, var(--color-text, #3a3a3a)); --schedule-active-border: var(--schedule-mode-active-border, var(--color-text));
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
border-radius: 25px; border-radius: 8px;
border: 1px solid var(--color-text, #ddd); border: 1px solid var(--color-border, #E2E8F0);
background-color: var(--bg-primary, #fff); background-color: var(--bg-primary, #fff);
color: var(--color-text, #222); color: var(--color-text, #222);
overflow: hidden; overflow: hidden;
@ -1451,3 +1544,33 @@
border-color: var(--primary-color, #007bff); border-color: var(--primary-color, #007bff);
color: var(--primary-color, #007bff); color: var(--primary-color, #007bff);
} }
/* Right panel tab bar (Nodes / Tracing) */
.rightTabBar {
display: flex;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
background: var(--bg-primary, #fff);
}
.rightTab {
flex: 1;
padding: 8px;
border: none;
background: transparent;
cursor: pointer;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #666);
transition: background 0.15s, color 0.15s;
}
.rightTab:hover {
background: var(--bg-hover, #f0f0f0);
}
.rightTabActive {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
box-shadow: inset 0 -2px 0 var(--primary-color, #007bff);
}

View file

@ -0,0 +1,805 @@
/**
* Automation2FlowEditor
*
* n8n-style flow builder with backend-driven node list.
* Workflow configuration (gear): primary start kind + invocations; canvas start node stays in sync.
*/
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { FaSpinner } from 'react-icons/fa';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchNodeTypes,
executeGraph,
fetchWorkflows,
fetchWorkflow,
createWorkflow,
updateWorkflow,
fetchVersions,
createDraftVersion,
publishVersion,
unpublishVersion,
archiveVersion,
createTemplateFromWorkflow,
copyTemplate,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
type AutoVersion,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
import { CanvasHeader } from './CanvasHeader';
import { WorkflowConfigurationModal } from './WorkflowConfigurationModal';
import { TemplatePicker } from './TemplatePicker';
import { getCategoryIcon } from '../nodes/shared/utils';
import { fromApiGraph, toApiGraph } from '../nodes/shared/graphUtils';
import {
syncCanvasStartNode,
buildInvocationsForPrimaryKind,
} from '../nodes/runtime/workflowStartSync';
import { buildNodeOutputsPreview, setPortTypeCatalog as setRegistryCatalog } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel';
import { RunTracingPanel } from './RunTracingPanel';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
const LOG = '[Automation2]';
const _buildDefaultInvocations = (runLabel: string): WorkflowEntryPoint[] =>
buildInvocationsForPrimaryKind('manual', [], runLabel);
interface Automation2FlowEditorProps {
instanceId: string;
mandateId?: string;
language?: string;
/** When set, load this workflow on mount (e.g. from workflows list edit) */
initialWorkflowId?: string | null;
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
dataSources?: EditorDataSource[];
featureDataSources?: EditorFeatureDataSource[];
onFileSelect?: (fileId: string, fileName?: string) => void;
onSourcesChanged?: () => void;
}
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ instanceId,
mandateId,
language = 'de',
initialWorkflowId,
pendingFiles,
onRemovePendingFile,
dataSources,
featureDataSources,
onFileSelect,
onSourcesChanged,
}) => {
const { t } = useLanguage();
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 [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState('');
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['trigger', 'input', 'flow', 'data', 'ai', 'email', 'sharepoint', 'clickup', 'trustee'])
);
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
const [executing, setExecuting] = useState(false);
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(() =>
_buildDefaultInvocations(t('Jetzt ausführen'))
);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [leftPanelOpen, setLeftPanelOpen] = useState(true);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [tracingNodeStatuses, setTracingNodeStatuses] = useState<Record<string, string>>({});
const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes');
const [udbTab, setUdbTab] = useState<UdbTab>('chats');
const udbContext: UdbContext = useMemo(() => ({
instanceId,
mandateId: mandateId || '',
featureInstanceId: instanceId,
}), [instanceId, mandateId]);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
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; }
});
const resizingRef = useRef<{ target: 'left' | 'right'; startX: number; startW: number } | null>(null);
useEffect(() => {
const _onMouseMove = (e: MouseEvent) => {
if (!resizingRef.current) return;
const { target, startX, startW } = resizingRef.current;
const delta = e.clientX - startX;
if (target === 'left') {
setLeftPanelWidth(Math.max(240, Math.min(600, startW + delta)));
} else {
setSidebarWidth(Math.max(200, Math.min(500, startW - delta)));
}
};
const _onMouseUp = () => {
if (!resizingRef.current) return;
const { target } = resizingRef.current;
resizingRef.current = null;
document.body.style.cursor = '';
document.body.style.userSelect = '';
if (target === 'left') {
setLeftPanelWidth((w) => { try { localStorage.setItem('flowEditor.leftPanelWidth', String(w)); } catch {} return w; });
} else {
setSidebarWidth((w) => { try { localStorage.setItem('flowEditor.sidebarWidth', String(w)); } catch {} return w; });
}
};
document.addEventListener('mousemove', _onMouseMove);
document.addEventListener('mouseup', _onMouseUp);
return () => { document.removeEventListener('mousemove', _onMouseMove); document.removeEventListener('mouseup', _onMouseUp); };
}, []);
const _startResize = useCallback((target: 'left' | 'right', e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = { target, startX: e.clientX, startW: target === 'left' ? leftPanelWidth : sidebarWidth };
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}, [leftPanelWidth, sidebarWidth]);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
const nodeOutputsPreview = useMemo(
() =>
buildNodeOutputsPreview(canvasNodes, nodeTypes, executeResult?.nodeOutputs as Record<string, unknown> | undefined),
[canvasNodes, nodeTypes, executeResult?.nodeOutputs]
);
const applyGraphWithSync = useCallback(
(graph: Automation2Graph | null | undefined, wfInvocations: WorkflowEntryPoint[] | undefined) => {
const inv = wfInvocations?.length ? wfInvocations : _buildDefaultInvocations(t('Jetzt ausführen'));
setInvocations(inv);
if (!graph?.nodes?.length) {
const synced = syncCanvasStartNode([], [], inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
return;
}
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
const synced = syncCanvasStartNode(nodes, connections, inv, nodeTypes, language);
setCanvasNodes(synced.nodes);
setCanvasConnections(synced.connections);
},
[nodeTypes, language, t]
);
const handleFromApiGraph = useCallback(
(graph: Automation2Graph, wfInvocations?: WorkflowEntryPoint[]) => {
applyGraphWithSync(graph, wfInvocations);
},
[applyGraphWithSync]
);
const handleExecute = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: t('Keine Nodes im Workflow.') });
return;
}
setExecuting(true);
setExecuteResult(null);
try {
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, {
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
if (result.runId) {
setTracingRunId(result.runId);
setRightTab('tracing');
}
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
setExecuting(false);
}
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, invocations, t]);
const handleSave = useCallback(async () => {
const graph = toApiGraph(canvasNodes, canvasConnections);
if (graph.nodes.length === 0) {
setExecuteResult({ success: false, error: t('Keine Nodes zum Speichern.') });
return;
}
setSaving(true);
try {
if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations });
setExecuteResult({ success: true } as ExecuteGraphResponse);
} else {
const label = await promptInput(t('Workflow-Name:'), {
title: t('Workflow speichern'),
defaultValue: t('Neuer Workflow'),
placeholder: t('Name des Workflows'),
});
if (!label) {
setSaving(false);
return;
}
const created = await createWorkflow(request, instanceId, {
label: label.trim() || t('Neuer Workflow'),
graph,
invocations,
});
setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations);
setWorkflows((prev) => [...prev, created]);
setExecuteResult({ success: true } as ExecuteGraphResponse);
}
} 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]);
const handleLoad = useCallback(
async (workflowId: string) => {
try {
const wf = await fetchWorkflow(request, instanceId, workflowId);
if (wf.graph) {
handleFromApiGraph(wf.graph, wf.invocations);
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
} catch (err: unknown) {
setExecuteResult({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
[request, instanceId, handleFromApiGraph, applyGraphWithSync]
);
const handleWorkflowSelect = useCallback(
(workflowId: string | null) => {
setCurrentWorkflowId(workflowId);
if (workflowId) handleLoad(workflowId);
else {
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}
},
[handleLoad, applyGraphWithSync, t]
);
const handleNew = useCallback(() => {
setCurrentWorkflowId(null);
setExecuteResult(null);
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [applyGraphWithSync, t]);
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const next = { ...n, parameters };
if (n.type === 'flow.switch' && 'cases' in parameters) {
const cases = (parameters.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleMergeNodeParameters = useCallback((nodeId: string, patch: Record<string, unknown>) => {
setCanvasNodes((prev) =>
prev.map((n) => {
if (n.id !== nodeId) return n;
const merged = { ...(n.parameters ?? {}), ...patch };
const next = { ...n, parameters: merged };
if (n.type === 'flow.switch' && 'cases' in merged) {
const cases = (merged.cases as unknown[]) ?? [];
next.outputs = Math.max(1, cases.length);
}
return next;
})
);
}, []);
const handleNodeUpdate = useCallback(
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
setCanvasNodes((prev) =>
prev.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
);
},
[]
);
const handleApplyWorkflowConfiguration = useCallback(
(next: WorkflowEntryPoint[]) => {
setInvocations(next);
setCanvasNodes((nodes) => {
const r = syncCanvasStartNode(nodes, canvasConnections, next, nodeTypes, language);
setCanvasConnections(r.connections);
return r.nodes;
});
},
[canvasConnections, nodeTypes, language]
);
const loadNodeTypes = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
setError(null);
try {
const data = await fetchNodeTypes(request, instanceId, language);
setNodeTypes(data.nodeTypes);
setCategories(data.categories);
if (data.portTypeCatalog) {
setPortTypeCatalog(data.portTypeCatalog);
setRegistryCatalog(data.portTypeCatalog as never);
}
if (data.systemVariables) setSystemVariables(data.systemVariables);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : String(err));
setNodeTypes([]);
setCategories([]);
} finally {
setLoading(false);
}
}, [instanceId, language, request]);
const loadWorkflows = useCallback(async () => {
if (!instanceId) return;
try {
const result = await fetchWorkflows(request, instanceId);
setWorkflows(Array.isArray(result) ? result : result.items);
} catch (e) {
console.error(`${LOG} loadWorkflows failed`, e);
}
}, [instanceId, request]);
useEffect(() => {
loadNodeTypes();
}, [loadNodeTypes]);
useEffect(() => {
loadWorkflows();
}, [loadWorkflows]);
const lastAppliedInitialRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (!initialWorkflowId || workflows.length === 0 || nodeTypes.length === 0) return;
if (lastAppliedInitialRef.current === initialWorkflowId) return;
lastAppliedInitialRef.current = initialWorkflowId;
handleWorkflowSelect(initialWorkflowId);
}, [initialWorkflowId, workflows, handleWorkflowSelect, nodeTypes.length]);
useEffect(() => {
if (loading || nodeTypes.length === 0) return;
if (currentWorkflowId || initialWorkflowId) return;
if (canvasNodes.length > 0) return;
applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
}, [
loading,
nodeTypes.length,
currentWorkflowId,
initialWorkflowId,
canvasNodes.length,
applyGraphWithSync,
t,
]);
const toggleCategory = useCallback((id: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const handleDropNodeType = useCallback(
(nodeTypeId: string, x: number, y: number) => {
if (nodeTypeId.startsWith('trigger.')) return;
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
if (!nt) return;
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
const label =
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
setCanvasNodes((prev) => [
...prev,
{
id,
type: nodeTypeId,
x,
y,
label,
title: label,
color: nt.meta?.color,
inputs: nt.inputs ?? 1,
outputs: nt.outputs ?? 1,
parameters: {},
},
]);
},
[nodeTypes, language]
);
const loadVersions = useCallback(async () => {
if (!instanceId || !currentWorkflowId) {
setVersions([]);
return;
}
try {
const v = await fetchVersions(request, instanceId, currentWorkflowId);
setVersions(v);
} catch (e) {
console.error(`${LOG} loadVersions failed`, e);
}
}, [instanceId, currentWorkflowId, request]);
useEffect(() => {
loadVersions();
}, [loadVersions]);
const handleVersionSelect = useCallback(
(versionId: string | null) => {
setCurrentVersionId(versionId);
if (versionId) {
const v = versions.find((ver) => ver.id === versionId);
if (v?.graph) {
handleFromApiGraph(v.graph, v.invocations);
}
}
},
[versions, handleFromApiGraph]
);
const handlePublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await publishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleUnpublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await unpublishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleArchiveVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await archiveVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleCreateDraft = useCallback(async () => {
if (!instanceId || !currentWorkflowId) return;
setVersionLoading(true);
try {
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
await loadVersions();
setCurrentVersionId(draft.id);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
}, [request, instanceId, currentWorkflowId, loadVersions]);
// Template: save current workflow as template
const [templateSaving, setTemplateSaving] = useState(false);
const handleSaveAsTemplate = useCallback(
async (scope: AutoTemplateScope) => {
if (!instanceId || !currentWorkflowId) return;
setTemplateSaving(true);
try {
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setTemplateSaving(false);
}
},
[request, instanceId, currentWorkflowId]
);
// Template: new workflow from template
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const handleNewFromTemplate = useCallback(
async (templateId: string) => {
if (!instanceId) return;
try {
const wf = await copyTemplate(request, instanceId, templateId);
setWorkflows((prev) => [...prev, wf]);
setCurrentWorkflowId(wf.id);
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
setTemplatePickerOpen(false);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
}
},
[request, instanceId, handleFromApiGraph]
);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try {
await updateWorkflow(request, instanceId, workflowId, { label: newName });
setWorkflows((prev) => prev.map((w) => w.id === workflowId ? { ...w, label: newName } : w));
} catch (e: unknown) {
console.error(`${LOG} rename failed`, e);
}
}, [request, instanceId]);
const _sidebarStyle = useMemo(() => ({ width: sidebarWidth }), [sidebarWidth]);
const renderSidebar = () => {
if (loading) {
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.loading}>
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
<p>{t('Lade Nodetypen…')}</p>
</div>
</div>
);
}
if (error) {
return (
<div className={styles.sidebar} style={_sidebarStyle}>
<div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
</div>
<div className={styles.error}>
<p>{error}</p>
<button className={styles.retryButton} onClick={loadNodeTypes}>
{t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<NodeSidebar
nodeTypes={nodeTypes}
categories={categories}
filter={filter}
onFilterChange={setFilter}
language={language}
expandedCategories={expandedCategories}
onToggleCategory={toggleCategory}
excludedCategories={sidebarExcludedCategories}
style={_sidebarStyle}
/>
);
};
const configurableSelected =
selectedNode &&
['input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'trigger.', 'flow.', 'file.', 'trustee.'].some((p) =>
selectedNode.type.startsWith(p)
);
return (
<div className={styles.container}>
{/* Left panel: Workspace (Chats / Dateien / Quellen) */}
{leftPanelOpen && (<>
<div style={{ width: leftPanelWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-primary, #fff)' }}>
<div className={styles.rightTabBar}>
{(['chats', 'files', 'sources'] as const).map((tab) => (
<button
key={tab}
className={`${styles.rightTab} ${udbTab === tab ? styles.rightTabActive : ''}`}
onClick={() => setUdbTab(tab)}
>
{{ chats: t('Chats'), files: t('Dateien'), sources: t('Quellen') }[tab]}
</button>
))}
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{udbTab === 'chats' ? (
<EditorChatPanel
instanceId={instanceId}
workflowId={currentWorkflowId}
onGraphUpdated={() => { if (currentWorkflowId) handleLoad(currentWorkflowId); }}
pendingFiles={pendingFiles}
onRemovePendingFile={onRemovePendingFile}
dataSources={dataSources}
featureDataSources={featureDataSources}
/>
) : (
<UnifiedDataBar
context={udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
onFileSelect={onFileSelect}
onSourcesChanged={onSourcesChanged}
/>
)}
</div>
</div>
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('left', e)} />
</>)}
{/* Canvas area - center */}
<div className={styles.canvas}>
<CanvasHeader
workflows={workflows}
currentWorkflowId={currentWorkflowId}
onWorkflowSelect={handleWorkflowSelect}
onNew={handleNew}
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setLeftPanelOpen((prev) => !prev)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeResult={executeResult}
versions={versions}
currentVersionId={currentVersionId}
onVersionSelect={handleVersionSelect}
onPublishVersion={handlePublishVersion}
onUnpublishVersion={handleUnpublishVersion}
onArchiveVersion={handleArchiveVersion}
onCreateDraft={handleCreateDraft}
versionLoading={versionLoading}
onSaveAsTemplate={handleSaveAsTemplate}
templateSaving={templateSaving}
onNewFromTemplate={() => setTemplatePickerOpen(true)}
onWorkflowRename={handleWorkflowRename}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<FlowCanvas
nodes={canvasNodes}
connections={canvasConnections}
nodeTypes={nodeTypes}
onNodesChange={setCanvasNodes}
onConnectionsChange={setCanvasConnections}
onDropNodeType={handleDropNodeType}
getLabel={(node) => node.title ?? node.label ?? node.type}
getCategoryIcon={getCategoryIcon}
onSelectionChange={setSelectedNode}
highlightedNodeIds={tracingRunId ? tracingNodeStatuses : undefined}
/>
</div>
{configurableSelected && selectedNode && (
<Automation2DataFlowProvider
node={selectedNode}
nodes={canvasNodes}
connections={canvasConnections}
nodeOutputsPreview={nodeOutputsPreview}
nodeTypes={nodeTypes}
language={language}
portTypeCatalog={portTypeCatalog as Record<string, never>}
systemVariables={systemVariables as Record<string, never>}
>
<NodeConfigPanel
node={selectedNode}
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
language={language}
onParametersChange={handleNodeParametersChange}
onMergeNodeParameters={handleMergeNodeParameters}
onNodeUpdate={handleNodeUpdate}
instanceId={instanceId}
request={request}
/>
</Automation2DataFlowProvider>
)}
</div>
</div>
{/* Right panel: Nodes + Tracing tabs */}
<div className={styles.resizeDivider} onMouseDown={(e) => _startResize('right', e)} />
<div style={{ width: sidebarWidth, flexShrink: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden', background: 'var(--bg-secondary, #f8f9fa)' }}>
<div className={styles.rightTabBar}>
<button
className={`${styles.rightTab} ${rightTab === 'nodes' ? styles.rightTabActive : ''}`}
onClick={() => setRightTab('nodes')}
>
{t('Knoten')}
</button>
<button
className={`${styles.rightTab} ${rightTab === 'tracing' ? styles.rightTabActive : ''}`}
onClick={() => { setRightTab('tracing'); if (!tracingRunId) setTracingRunId('select'); }}
>
{t('Ablaufverfolgung')}
</button>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{rightTab === 'nodes' ? (
renderSidebar()
) : (
<RunTracingPanel
instanceId={instanceId}
runId={tracingRunId === 'select' ? null : tracingRunId}
onNodeSelect={(nodeId) => {
const node = canvasNodes.find((n) => n.id === nodeId);
if (node) setSelectedNode(node);
}}
onActiveStepsChange={setTracingNodeStatuses}
/>
)}
</div>
</div>
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}
onClose={() => setWorkflowSettingsOpen(false)}
invocations={invocations}
onApply={handleApplyWorkflowConfiguration}
/>
<TemplatePicker
open={templatePickerOpen}
onClose={() => setTemplatePickerOpen(false)}
onSelect={handleNewFromTemplate}
instanceId={instanceId}
request={request}
/>
</div>
);
};
export default Automation2FlowEditor;

View file

@ -0,0 +1,405 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive, FaDatabase, FaBookmark, FaCaretDown } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion, AutoTemplateScope } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
onToggleChat?: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
executeResult: ExecuteGraphResponse | null;
versions?: AutoVersion[];
currentVersionId?: string | null;
onVersionSelect?: (versionId: string | null) => void;
onPublishVersion?: (versionId: string) => void;
onUnpublishVersion?: (versionId: string) => void;
onArchiveVersion?: (versionId: string) => void;
onCreateDraft?: () => void;
versionLoading?: boolean;
onSaveAsTemplate?: (scope: AutoTemplateScope) => void;
templateSaving?: boolean;
onNewFromTemplate?: () => void;
onWorkflowRename?: (workflowId: string, newName: string) => void;
}
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
return {
draft: { label: t('Entwurf'), color: 'var(--warning-color, #ffc107)' },
published: { label: t('Veröffentlicht'), color: 'var(--success-color, #28a745)' },
archived: { label: t('Archiviert'), color: 'var(--text-secondary, #666)' },
};
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
onToggleChat,
saving,
executing,
hasNodes,
executeResult,
versions,
currentVersionId,
onVersionSelect,
onPublishVersion,
onUnpublishVersion,
onArchiveVersion,
onCreateDraft,
versionLoading,
onSaveAsTemplate,
templateSaving,
onNewFromTemplate,
onWorkflowRename,
}) => {
const { t } = useLanguage();
const statusBadge = _getStatusBadge(t);
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
const badge = statusBadge[currentStatus] || statusBadge.draft;
const [newMenuOpen, setNewMenuOpen] = useState(false);
const newMenuRef = useRef<HTMLDivElement>(null);
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
const templateMenuRef = useRef<HTMLDivElement>(null);
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState('');
const nameInputRef = useRef<HTMLInputElement>(null);
const currentWorkflow = workflows.find((w) => w.id === currentWorkflowId);
const _startNameEdit = useCallback(() => {
if (!currentWorkflowId || !onWorkflowRename) return;
setNameValue(currentWorkflow?.label || '');
setEditingName(true);
}, [currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
const _commitNameEdit = useCallback(() => {
setEditingName(false);
const trimmed = nameValue.trim();
if (!trimmed || !currentWorkflowId || !onWorkflowRename) return;
if (trimmed !== currentWorkflow?.label) {
onWorkflowRename(currentWorkflowId, trimmed);
}
}, [nameValue, currentWorkflowId, currentWorkflow?.label, onWorkflowRename]);
useEffect(() => {
if (editingName && nameInputRef.current) {
nameInputRef.current.focus();
nameInputRef.current.select();
}
}, [editingName]);
useEffect(() => {
const _handleClickOutside = (e: MouseEvent) => {
if (newMenuRef.current && !newMenuRef.current.contains(e.target as Node)) setNewMenuOpen(false);
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) setTemplateMenuOpen(false);
};
document.addEventListener('mousedown', _handleClickOutside);
return () => document.removeEventListener('mousedown', _handleClickOutside);
}, []);
const scopeLabels = useMemo(
() =>
({
user: t('Meine Vorlagen'),
instance: t('Instanz'),
mandate: t('Mandant'),
}) as Record<string, string>,
[t]
);
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
{/* Workflow name: inline editable */}
{currentWorkflowId && currentWorkflow ? (
editingName ? (
<input
ref={nameInputRef}
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={_commitNameEdit}
onKeyDown={(e) => { if (e.key === 'Enter') _commitNameEdit(); if (e.key === 'Escape') setEditingName(false); }}
style={{ padding: '0.25rem 0.4rem', fontSize: '0.95rem', fontWeight: 600, border: '1px solid var(--primary-color, #007bff)', borderRadius: 4, outline: 'none', minWidth: 140, maxWidth: 300 }}
/>
) : (
<h4
className={styles.canvasTitle}
style={{ margin: 0, cursor: onWorkflowRename ? 'pointer' : 'default', fontSize: '0.95rem', fontWeight: 600 }}
onClick={_startNameEdit}
title={onWorkflowRename ? t('Klicken zum Umbenennen') : undefined}
>
{currentWorkflow.label}
</h4>
)
) : (
<h4 className={styles.canvasTitle} style={{ margin: 0, fontStyle: 'italic', opacity: 0.6 }}>
{t('Neuer Workflow')}
</h4>
)}
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title={t('Workflowkonfiguration Einstieg/Starts')}
aria-label={t('Workflow-Konfiguration')}
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
{/* Split "Neu" button */}
<div ref={newMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<div style={{ display: 'flex' }}>
<button type="button" className={styles.retryButton} onClick={onNew} style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}>
{t('Neu')}
</button>
<button
type="button"
className={styles.retryButton}
onClick={() => setNewMenuOpen((p) => !p)}
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0, paddingLeft: 4, paddingRight: 6, borderLeft: '1px solid rgba(0,0,0,0.15)' }}
title={t('Neu aus Vorlage')}
>
<FaCaretDown style={{ fontSize: '0.7rem' }} />
</button>
</div>
{newMenuOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, zIndex: 100, background: 'var(--bg-primary, #fff)', border: '1px solid var(--border-color, #e0e0e0)', borderRadius: 6, boxShadow: '0 4px 12px rgba(0,0,0,0.15)', minWidth: 180, marginTop: 4 }}>
<button
type="button"
onClick={() => { onNew(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem' }}
>
{t('Leerer Workflow')}
</button>
{onNewFromTemplate && (
<button
type="button"
onClick={() => { onNewFromTemplate(); setNewMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: '1px solid var(--border-color, #e0e0e0)' }}
>
{t('Aus Vorlage…')}
</button>
)}
</div>
)}
</div>
<button
type="button"
className={styles.retryButton}
onClick={onSave}
disabled={saving || !hasNodes}
>
{saving ? <FaSpinner className={styles.spinner} /> : t('Speichern')}
</button>
{/* Save as template */}
{currentWorkflowId && onSaveAsTemplate && (
<div ref={templateMenuRef} style={{ position: 'relative', display: 'inline-block' }}>
<button
type="button"
className={styles.retryButton}
onClick={() => setTemplateMenuOpen((p) => !p)}
disabled={templateSaving}
title={t('Als Vorlage speichern')}
>
{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 }}>
{(['user', 'instance', 'mandate'] as const).map((s) => (
<button
key={s}
type="button"
onClick={() => { onSaveAsTemplate(s); setTemplateMenuOpen(false); }}
style={{ display: 'block', width: '100%', textAlign: 'left', padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.85rem', borderTop: s !== 'user' ? '1px solid var(--border-color, #e0e0e0)' : undefined }}
>
{scopeLabels[s]}
</button>
))}
</div>
)}
</div>
)}
<select
value={currentWorkflowId ?? ''}
onChange={(e) => {
const id = e.target.value ? e.target.value : null;
onWorkflowSelect(id);
}}
style={{ padding: '0.4rem', minWidth: 180 }}
>
<option value="">{t('Workflow laden')}</option>
{workflows.map((w) => (
<option key={w.id} value={w.id}>
{w.label}
</option>
))}
</select>
<button
type="button"
className={styles.retryButton}
onClick={onExecute}
disabled={executing || !hasNodes}
>
{executing ? (
<>
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
{t('Ausführen…')}
</>
) : (
<>
<FaPlay style={{ marginRight: '0.5rem' }} />
{t('Ausführen')}
</>
)}
</button>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title={t('Workspace-Panel: Chats, Dateien, Quellen')}>
<FaDatabase style={{ marginRight: '0.4rem' }} />
{t('Workspace')}
</button>
)}
</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>
<select
value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)}
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading}
>
<option value="">{t('Aktuelle')}</option>
{versions.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} ({statusBadge[v.status]?.label ?? v.status})
</option>
))}
</select>
<span
style={{
padding: '2px 8px',
borderRadius: 10,
fontSize: '0.75rem',
fontWeight: 600,
background: badge.color + '22',
color: badge.color,
}}
>
{badge.label}
</span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version veröffentlichen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichen')}
</button>
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Veröffentlichung zurücknehmen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
{t('Veröffentlichung aufheben')}
</button>
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title={t('Version archivieren')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaArchive style={{ marginRight: 4 }} />
Archiv
</button>
)}
{onCreateDraft && (
<button
type="button"
className={styles.retryButton}
onClick={onCreateDraft}
disabled={versionLoading}
title={t('Neuen Entwurf erstellen')}
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
+ Entwurf
</button>
)}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
</div>
)}
{executeResult && (
<div
style={{
marginTop: '0.5rem',
padding: '0.5rem',
borderRadius: 6,
fontSize: '0.875rem',
background: executeResult.success
? 'rgba(40,167,69,0.15)'
: (executeResult as { paused?: boolean }).paused
? 'rgba(0,123,255,0.15)'
: 'rgba(220,53,69,0.15)',
color: executeResult.success
? 'var(--success-color,#28a745)'
: (executeResult as { paused?: boolean }).paused
? 'var(--primary-color,#007bff)'
: 'var(--danger-color,#dc3545)',
}}
>
{executeResult.success ? (
<>{t('Ausführung abgeschlossen')}</>
) : (executeResult as { paused?: boolean }).paused ? (
<>
Workflow pausiert. Öffne <strong>{t('Workflows/Tasks')}</strong> in der Sidebar, um den
Task zu bearbeiten.
</>
) : (
<> {executeResult.error ?? t('Unbekannter Fehler')}</>
)}
</div>
)}
</div>
);
};

View file

@ -0,0 +1,409 @@
/**
* EditorChatPanel
*
* 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
* - Data Sources: 🔗 picker button next to input (toggle-select from active sources)
*/
import React, { useState, useCallback, useRef } from 'react';
import { startSseStream } from '../../../utils/sseClient';
import { ChatMessageList } from '../../Chat';
import type { ChatMessage } from '../../Chat';
import { getPageIcon } from '../../../config/pageRegistry';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface PendingFile {
fileId: string;
fileName: string;
itemType?: 'file' | 'folder';
}
export interface EditorDataSource {
id: string;
label: string;
path?: string;
sourceType?: string;
}
export interface EditorFeatureDataSource {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
label: string;
}
interface EditorChatPanelProps {
instanceId: string;
workflowId: string | null;
onGraphUpdated?: () => void;
pendingFiles?: PendingFile[];
onRemovePendingFile?: (fileId: string) => void;
dataSources?: EditorDataSource[];
featureDataSources?: EditorFeatureDataSource[];
}
let _msgCounter = 0;
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
workflowId,
onGraphUpdated,
pendingFiles = [],
onRemovePendingFile,
dataSources = [],
featureDataSources = [],
}) => {
const { t } = useLanguage();
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [prompt, setPrompt] = useState('');
const [attachedDataSourceIds, setAttachedDataSourceIds] = useState<string[]>([]);
const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState<string[]>([]);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [treeDropOver, setTreeDropOver] = useState(false);
const abortRef = useRef<(() => void) | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const _toggleDataSource = useCallback((dsId: string) => {
setAttachedDataSourceIds(prev =>
prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId],
);
}, []);
const _toggleFeatureDataSource = useCallback((fdsId: string) => {
setAttachedFeatureDataSourceIds(prev =>
prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId],
);
}, []);
const _handleSend = useCallback(() => {
const trimmed = prompt.trim();
if (!workflowId || loading || !trimmed) return;
const fileIds = pendingFiles.map(f => f.fileId);
const body: Record<string, unknown> = {
message: trimmed,
conversationHistory: messages.map(m => ({ role: m.role, message: m.content })),
userLanguage: navigator.language?.slice(0, 2) || 'de',
};
if (fileIds.length > 0) body.fileIds = fileIds;
if (attachedDataSourceIds.length > 0) body.dataSourceIds = attachedDataSourceIds;
if (attachedFeatureDataSourceIds.length > 0) body.featureDataSourceIds = attachedFeatureDataSourceIds;
const userMsg: ChatMessage = {
id: `user-${++_msgCounter}`,
role: 'user',
content: trimmed,
timestamp: Date.now(),
};
setMessages(prev => [...prev, userMsg]);
setPrompt('');
setShowSourcePicker(false);
setLoading(true);
const assistantId = `asst-${++_msgCounter}`;
let accumulated = '';
setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]);
const cleanup = startSseStream({
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body,
handlers: {
onChunk: (event) => {
if (event.content) {
accumulated += event.content;
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
}
},
onRawEvent: (event) => {
if (event.type === 'message' && event.content) {
accumulated += event.content;
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: accumulated } : m));
}
if (event.type === 'toolResult' || event.type === 'toolCall') {
onGraphUpdated?.();
}
},
onComplete: () => {
if (!accumulated) {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: t('Fertig.') } : m));
}
onGraphUpdated?.();
setLoading(false);
},
onError: (event) => {
const errText = event.content || t('Anfrage fehlgeschlagen');
if (!accumulated) {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${errText}` } : m));
}
setLoading(false);
},
onStopped: () => setLoading(false),
},
onConnectionError: (err) => {
setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m));
setLoading(false);
},
onStreamEnd: () => setLoading(false),
});
abortRef.current = cleanup;
}, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]);
const _handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_handleSend();
}
}, [_handleSend]);
const _handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes('application/tree-items')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true);
}
}, []);
const _handleDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handleDrop = useCallback((e: React.DragEvent) => {
setTreeDropOver(false);
const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson) {
e.preventDefault();
e.stopPropagation();
}
}, []);
const hasAttachments = pendingFiles.length > 0 || attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0;
const sourceCount = attachedDataSourceIds.length + attachedFeatureDataSourceIds.length;
const hasSourceOptions = dataSources.length > 0 || featureDataSources.length > 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList
messages={messages}
isProcessing={loading}
emptyMessage={t('Beschreiben Sie, was Sie tun möchten')}
/>
{/* Pending files (from UDB drag/click) */}
{pendingFiles.length > 0 && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
}}>
{pendingFiles.map(pf => (
<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'}`,
}}>
{pf.itemType === 'folder' ? '\uD83D\uDCC1' : '\uD83D\uDCCE'} {pf.fileName.length > 20 ? pf.fileName.slice(0, 20) + '...' : pf.fileName}
{onRemovePendingFile && (
<button onClick={() => onRemovePendingFile(pf.fileId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#e65100', padding: 0, lineHeight: 1,
}}>x</button>
)}
</span>
))}
</div>
)}
{/* Attached data sources chips */}
{(attachedDataSourceIds.length > 0 || attachedFeatureDataSourceIds.length > 0) && (
<div style={{
padding: '6px 12px', display: 'flex', gap: 4, flexWrap: 'wrap',
borderTop: pendingFiles.length > 0 ? 'none' : '1px solid var(--border-color, #e0e0e0)',
background: '#fafafa',
}}>
{attachedDataSourceIds.map(dsId => {
const ds = dataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
\uD83D\uDD17 {ds?.label || dsId}
<button onClick={() => _toggleDataSource(dsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
{attachedFeatureDataSourceIds.map(fdsId => {
const fds = featureDataSources.find(d => d.id === fdsId);
const fdsIcon = fds ? getPageIcon(`feature.${fds.featureCode}`) : null;
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 11 }}>{fdsIcon || '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFeatureDataSource(fdsId)} style={{
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1,
}}>x</button>
</span>
);
})}
</div>
)}
{/* Input area */}
<div
style={{
borderTop: hasAttachments ? 'none' : '1px solid var(--border-color, #e0e0e0)',
padding: '8px 12px',
display: 'flex', gap: 6, alignItems: 'flex-end',
outline: treeDropOver ? '2px dashed var(--primary-color, #F25843)' : 'none',
background: treeDropOver ? 'rgba(242, 88, 67, 0.08)' : undefined,
transition: 'background 0.15s, outline 0.15s',
}}
onDragOver={_handleDragOver}
onDragLeave={_handleDragLeave}
onDrop={_handleDrop}
>
<textarea
ref={textareaRef}
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={workflowId ? t('Beschreiben Sie eine Änderung') : t('Speichern Sie zuerst den Workflow')}
disabled={!workflowId || loading}
style={{
flex: 1, minHeight: 36, maxHeight: 100, resize: 'vertical',
padding: '8px 10px', borderRadius: 8,
border: '1px solid var(--border-color, #ccc)',
fontSize: 13, fontFamily: 'inherit', outline: 'none',
}}
rows={1}
/>
{/* Source picker button */}
{hasSourceOptions && (
<div style={{ position: 'relative' }} ref={pickerRef}>
<button
onClick={() => setShowSourcePicker(prev => !prev)}
disabled={loading || !workflowId}
title={t('Datenquellen anhängen')}
style={{
width: 36, height: 36, borderRadius: 8,
border: '1px solid var(--border-color, #ddd)',
background: sourceCount > 0 ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: sourceCount > 0 ? '#2e7d32' : '#666',
cursor: loading || !workflowId ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: loading ? 0.5 : 1, position: 'relative',
}}
>
{'\uD83D\uDD17'}
{sourceCount > 0 && (
<span style={{
position: 'absolute', top: -4, right: -4,
background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700,
borderRadius: '50%', width: 16, height: 16,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{sourceCount}</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: '#fff', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 260, overflowY: 'auto',
}}>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>
{t('Aktive Quellen auswählen')}
</div>
{dataSources.map(ds => {
const isSelected = attachedDataSourceIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDataSource(ds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#e8f5e9' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #2e7d32' : '2px solid #ccc',
background: isSelected ? '#2e7d32' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ds.label || ds.path || ds.id}
</span>
</div>
);
})}
{featureDataSources.length > 0 && (
<>
<div style={{ padding: '8px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: '1px solid #f0f0f0', borderBottom: '1px solid #f0f0f0' }}>
{t('Feature-Datenquellen')}
</div>
{featureDataSources.map(fds => {
const isSelected = attachedFeatureDataSourceIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFeatureDataSource(fds.id)} style={{
padding: '8px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: isSelected ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isSelected ? '#f3e5f5' : ''; }}
>
<span style={{
width: 14, height: 14, borderRadius: 3,
border: isSelected ? '2px solid #7b1fa2' : '2px solid #ccc',
background: isSelected ? '#7b1fa2' : 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0,
}}>{isSelected ? '\u2713' : ''}</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>
{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{fds.label || fds.featureCode} {fds.tableName}
</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{loading ? (
<button onClick={() => abortRef.current?.()} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: '#f44336', color: '#fff', cursor: 'pointer', fontWeight: 600, fontSize: 12,
}}>{t('Stopp')}</button>
) : (
<button onClick={_handleSend} disabled={!prompt.trim() || !workflowId} style={{
padding: '8px 14px', borderRadius: 8, border: 'none',
background: prompt.trim() && workflowId ? 'var(--primary-color, #F25843)' : '#ccc',
color: '#fff', cursor: prompt.trim() && workflowId ? 'pointer' : 'default',
fontWeight: 600, fontSize: 12,
}}>{t('Senden')}</button>
)}
</div>
</div>
);
};

View file

@ -4,9 +4,11 @@
*/ */
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NodeType } from '../../../api/automation2Api'; import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export interface CanvasNode { export interface CanvasNode {
id: string; id: string;
type: string; type: string;
@ -19,6 +21,8 @@ export interface CanvasNode {
inputs: number; inputs: number;
outputs: number; outputs: number;
parameters?: Record<string, unknown>; parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
} }
export interface CanvasConnection { export interface CanvasConnection {
@ -34,6 +38,30 @@ const NODE_HEIGHT = 72;
const HANDLE_SIZE = 12; const HANDLE_SIZE = 12;
const HANDLE_OFFSET = HANDLE_SIZE / 2; const HANDLE_OFFSET = HANDLE_SIZE / 2;
/** Soft port compatibility check: returns 'ok' | 'warning' | 'error' */
function _checkConnectionCompatibility(
sourceNode: CanvasNode,
sourceOutputIdx: number,
targetNode: CanvasNode,
targetInputIdx: number,
nodeTypes: NodeType[],
): 'ok' | 'warning' {
const srcType = nodeTypes.find((nt) => nt.id === sourceNode.type);
const tgtType = nodeTypes.find((nt) => nt.id === targetNode.type);
if (!srcType?.outputPorts || !tgtType?.inputPorts) return 'ok';
const srcPort = srcType.outputPorts[sourceOutputIdx];
const tgtPort = tgtType.inputPorts[targetInputIdx];
if (!srcPort || !tgtPort) return 'ok';
const srcSchema = srcPort.schema;
const accepts = tgtPort.accepts;
if (!accepts || accepts.length === 0) return 'ok';
if (accepts.includes('Transit')) return 'ok';
if (accepts.includes(srcSchema)) return 'ok';
return 'warning';
}
interface FlowCanvasProps { interface FlowCanvasProps {
nodes: CanvasNode[]; nodes: CanvasNode[];
connections: CanvasConnection[]; connections: CanvasConnection[];
@ -44,10 +72,17 @@ interface FlowCanvasProps {
getLabel: (node: CanvasNode) => string; getLabel: (node: CanvasNode) => string;
getCategoryIcon: (category: string) => React.ReactNode; getCategoryIcon: (category: string) => React.ReactNode;
onSelectionChange?: (node: CanvasNode | null) => void; onSelectionChange?: (node: CanvasNode | null) => void;
highlightedNodeIds?: Record<string, string>;
} }
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ const HIGHLIGHT_COLORS: Record<string, string> = {
nodes, running: '#f0ad4e',
completed: '#28a745',
failed: '#dc3545',
skipped: '#6c757d',
};
export const FlowCanvas: React.FC<FlowCanvasProps> = ({ nodes,
connections, connections,
nodeTypes, nodeTypes,
onNodesChange, onNodesChange,
@ -56,11 +91,14 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
getLabel, getLabel,
getCategoryIcon, getCategoryIcon,
onSelectionChange, onSelectionChange,
highlightedNodeIds,
}) => { }) => {
const { t } = useLanguage();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set()); const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(new Set());
const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null; const selectedNodeId = selectedNodeIds.size === 1 ? [...selectedNodeIds][0] : null;
const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null); const [selectedConnectionId, setSelectedConnectionId] = useState<string | null>(null);
const [connectionWarnings, setConnectionWarnings] = useState<Record<string, boolean>>({});
const [selectionBox, setSelectionBox] = useState<{ const [selectionBox, setSelectionBox] = useState<{
startX: number; startX: number;
startY: number; startY: number;
@ -236,6 +274,18 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
targetId: targetNodeId, targetId: targetNodeId,
targetHandle: targetHandleIndex, targetHandle: targetHandleIndex,
}; };
const srcNode = nodes.find((n) => n.id === connectingFrom.nodeId);
const tgtNode = nodes.find((n) => n.id === targetNodeId);
if (srcNode && tgtNode) {
const sourceOutputIdx = connectingFrom.handleIndex >= srcNode.inputs
? connectingFrom.handleIndex - srcNode.inputs : 0;
const compat = _checkConnectionCompatibility(srcNode, sourceOutputIdx, tgtNode, targetHandleIndex, nodeTypes);
if (compat === 'warning') {
setConnectionWarnings((prev) => ({ ...prev, [newConn.id]: true }));
}
}
onConnectionsChange([...connections, newConn]); onConnectionsChange([...connections, newConn]);
setConnectingFrom(null); setConnectingFrom(null);
setDragPos(null); setDragPos(null);
@ -510,17 +560,30 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
> >
{selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && ( {selectedNodeIds.size > 1 && !selectedConnectionId && !connectingFrom && (
<div className={styles.connectionHint}> <div className={styles.connectionHint}>
{selectedNodeIds.size} Nodes ausgewählt <kbd>Entf</kbd> zum Löschen Ziehen zum Verschieben <kbd>Shift</kbd>+Klick zum Hinzufügen/Entfernen {selectedNodeIds.size} {t('Knoten ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Ziehen zum Verschieben')}
{' · '}
<kbd>Shift</kbd>
{t('+Klick zum Hinzufügen oder Entfernen')}
</div> </div>
)} )}
{connectingFrom && !selectedConnectionId && ( {connectingFrom && !selectedConnectionId && (
<div className={styles.connectionHint}> <div className={styles.connectionHint}>
Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang <kbd>Esc</kbd> zum Abbrechen {t('Ziehen Sie zum Eingang oder klicken Sie auf einen Eingang')}
{' · '}
<kbd>Esc</kbd> {t('zum Abbrechen')}
</div> </div>
)} )}
{selectedConnectionId && ( {selectedConnectionId && (
<div className={styles.connectionHint}> <div className={styles.connectionHint}>
Pfeil ausgewählt <kbd>Entf</kbd> zum Löschen Klicken Sie auf einen anderen Eingang zum Umleiten {t('Verbindungspfeil ausgewählt')}
{' · '}
<kbd>Entf</kbd> {t('zum Löschen')}
{' · '}
{t('Anderen Eingang anklicken zum Umleiten')}
</div> </div>
)} )}
<div <div
@ -559,6 +622,16 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
> >
<polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" /> <polygon points="0 0, 10 3.5, 0 7" fill="var(--primary-color, #007bff)" />
</marker> </marker>
<marker
id="arrowhead-warning"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#FF9800" />
</marker>
</defs> </defs>
{connections.map((c) => { {connections.map((c) => {
const srcNode = nodes.find((n) => n.id === c.sourceId); const srcNode = nodes.find((n) => n.id === c.sourceId);
@ -569,6 +642,12 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const dx = tgt.x - src.x; const dx = tgt.x - src.x;
const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`; const pathD = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
const isSelected = selectedConnectionId === c.id; const isSelected = selectedConnectionId === c.id;
const isWarning = connectionWarnings[c.id];
const strokeColor = isSelected
? 'var(--primary-color, #007bff)'
: isWarning
? '#FF9800'
: 'var(--text-secondary, #666)';
return ( return (
<g <g
key={c.id} key={c.id}
@ -576,7 +655,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
role="button" role="button"
tabIndex={-1} tabIndex={-1}
aria-label="Verbindung auswählen (Entf zum Löschen, klicken Sie auf einen anderen Eingang zum Umleiten)" aria-label={t('Verbindung auswählen, Entf zum Löschen')}
> >
<path <path
d={pathD} d={pathD}
@ -588,11 +667,15 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
<path <path
d={pathD} d={pathD}
fill="none" fill="none"
stroke={isSelected ? 'var(--primary-color, #007bff)' : 'var(--text-secondary, #666)'} stroke={strokeColor}
strokeWidth={isSelected ? 3 : 2} strokeWidth={isSelected ? 3 : 2}
strokeDasharray={isWarning && !isSelected ? '6 3' : undefined}
markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'} markerEnd={isSelected ? 'url(#arrowhead-selected)' : 'url(#arrowhead)'}
pointerEvents="none" pointerEvents="none"
/> />
{isWarning && !isSelected && (
<title>{t('Typeninkompatibilität: Ausgabetyp')}</title>
)}
</g> </g>
); );
})} })}
@ -621,18 +704,21 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
const isSelected = selectedNodeIds.has(node.id); const isSelected = selectedNodeIds.has(node.id);
const isEditingTitle = editingNodeId === node.id && editingField === 'title'; const isEditingTitle = editingNodeId === node.id && editingField === 'title';
const displayTitle = node.title ?? node.label ?? getLabel(node); const displayTitle = node.title ?? node.label ?? getLabel(node);
const hlStatus = highlightedNodeIds?.[node.id];
const hlColor = hlStatus ? HIGHLIGHT_COLORS[hlStatus] : null;
return ( return (
<div <div
key={node.id} key={node.id}
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`} className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''} ${hlStatus ? styles.canvasNodeHighlighted : ''}`}
style={{ style={{
left: node.x, left: node.x,
top: node.y, top: node.y,
width: NODE_WIDTH, width: NODE_WIDTH,
height: NODE_HEIGHT, height: NODE_HEIGHT,
borderColor: color, borderColor: hlColor || color,
backgroundColor: `${color}15`, backgroundColor: hlColor ? `${hlColor}20` : `${color}15`,
boxShadow: hlStatus === 'running' ? `0 0 12px ${hlColor}80` : undefined,
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => { onMouseDown={(e) => {
@ -687,8 +773,8 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
outputLabel ?? outputLabel ??
(selectedConnectionId && !isOutput (selectedConnectionId && !isOutput
? used ? used
? 'Aktuelles Ziel klicken zum Abwählen' ? t('Aktuelles Ziel klicken, um abzuwählen')
: 'Klicken zum Umleiten' : t('Klicken zum Umleiten')
: undefined) : undefined)
} }
/> />
@ -743,7 +829,13 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
{displayTitle} {displayTitle}
</span> </span>
)} )}
{node.comment && (
<span className={styles.canvasNodeComment}>{node.comment}</span>
)}
</div> </div>
{node.comment && (
<div className={styles.canvasNodeCommentTooltip}>{node.comment}</div>
)}
</div> </div>
</div> </div>
); );
@ -761,7 +853,7 @@ export const FlowCanvas: React.FC<FlowCanvasProps> = ({
)} )}
{nodes.length === 0 && ( {nodes.length === 0 && (
<div className={styles.canvasPlaceholder}> <div className={styles.canvasPlaceholder}>
<p>Nodes aus der Liste links hierher ziehen.</p> <p>{t('Knoten aus der Liste links ziehen')}</p>
</div> </div>
)} )}
</div> </div>

View file

@ -1,16 +1,18 @@
/** /**
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes. * NodeConfigPanel - Generic parameter renderer for all node types.
* Delegates to config components from nodes/configs. * Renders each parameter using FRONTEND_TYPE_RENDERERS based on frontendType.
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas'; import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../../api/automation2Api'; import type { NodeType, NodeTypeParameter } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/automation2Api'; import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { NODE_CONFIG_REGISTRY } from '../nodes/configs'; import { FRONTEND_TYPE_RENDERERS } from '../nodes/frontendTypeRenderers';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface NodeConfigPanelProps { interface NodeConfigPanelProps {
node: CanvasNode | null; node: CanvasNode | null;
nodeType: NodeType | undefined; nodeType: NodeType | undefined;
@ -22,18 +24,16 @@ interface NodeConfigPanelProps {
request?: ApiRequestFunction; request?: ApiRequestFunction;
} }
const CONFIGURABLE_PREFIXES = ['trigger.', 'input.', 'ai.', 'email.', 'sharepoint.', 'clickup.', 'flow.', 'file.']; export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({ node,
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
node,
nodeType, nodeType,
language, language,
onParametersChange, onParametersChange,
onMergeNodeParameters, onMergeNodeParameters: _onMergeNodeParameters,
onNodeUpdate, onNodeUpdate,
instanceId, instanceId,
request, request,
}) => { }) => {
const { t } = useLanguage();
const [params, setParams] = useState<Record<string, unknown>>({}); const [params, setParams] = useState<Record<string, unknown>>({});
const nodeIdRef = useRef<string | undefined>(undefined); const nodeIdRef = useRef<string | undefined>(undefined);
nodeIdRef.current = node?.id; nodeIdRef.current = node?.id;
@ -52,7 +52,6 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
}; };
}, [node?.id]); }, [node?.id]);
/** Do not call onParametersChange (parent setState) inside setParams updater — React forbids updating a parent during a child's state update. */
const updateParam = useCallback( const updateParam = useCallback(
(key: string, value: unknown) => { (key: string, value: unknown) => {
setParams((prev) => { setParams((prev) => {
@ -73,48 +72,51 @@ export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
[onParametersChange] [onParametersChange]
); );
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p)); if (!node || !nodeType) return null;
if (!node || !isConfigurable) return null;
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
if (!ConfigRenderer) {
return (
<div className={styles.nodeConfigPanel}>
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<p>No configuration for {node.type}</p>
</div>
);
}
const isTrigger = node.type.startsWith('trigger.'); const isTrigger = node.type.startsWith('trigger.');
const showNameField = onNodeUpdate && !isTrigger; const showNameField = onNodeUpdate && !isTrigger;
const parameters = nodeType.parameters || [];
return ( return (
<div className={styles.nodeConfigPanel}> <div className={styles.nodeConfigPanel}>
{showNameField && ( {showNameField && (
<div className={styles.nodeConfigNameRow}> <div className={styles.nodeConfigNameRow}>
<label htmlFor="node-config-name">Bezeichnung</label> <label htmlFor="node-config-name">{t('Bezeichnung')}</label>
<input <input
id="node-config-name" id="node-config-name"
type="text" type="text"
value={node.title ?? ''} value={node.title ?? ''}
onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })} onChange={(e) => onNodeUpdate(node.id, { title: e.target.value })}
placeholder="z.B. Kundenformular, Prüfen Land" placeholder={t('z.B. Kundenformular prüfen, Land')}
/> />
<p className={styles.nodeConfigNameHint}> <p className={styles.nodeConfigNameHint}>
Wird im Data Picker angezeigt, um diesen Node zu identifizieren. {t('Wird im Data Picker angezeigt, um diesen Node zu identifizieren.')}
</p> </p>
</div> </div>
)} )}
<h4>{getLabel(nodeType?.label, language) || node.type}</h4> <h4>{getLabel(nodeType?.label, language) || node.type}</h4>
<ConfigRenderer {nodeType?.description && (
params={params} <p className={styles.nodeConfigDescription}>
updateParam={updateParam} {getLabel(nodeType.description, language)}
</p>
)}
{parameters.map((param: NodeTypeParameter) => {
const frontendType = param.frontendType || 'text';
const Renderer = FRONTEND_TYPE_RENDERERS[frontendType] ?? FRONTEND_TYPE_RENDERERS.text;
return (
<Renderer
key={param.name}
param={param}
value={params[param.name] ?? param.default}
onChange={(val: unknown) => updateParam(param.name, val)}
allParams={params}
instanceId={instanceId} instanceId={instanceId}
request={request} request={request}
nodeType={node.type} nodeType={node.type}
mergeNodeParameters={onMergeNodeParameters}
/> />
);
})}
</div> </div>
); );
}; };

View file

@ -0,0 +1,53 @@
/**
* NodeListItem - Draggable node type item for the sidebar.
* Used in both regular categories and I/O sub-groups.
*/
import React from 'react';
import type { NodeType } from '../../../api/workflowApi';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css';
interface NodeListItemProps {
node: NodeType;
language: string;
getLabel: GetLabelFn;
getCategoryIcon?: (categoryId: string) => React.ReactNode;
}
export const NodeListItem: React.FC<NodeListItemProps> = ({
node,
language,
getLabel,
getCategoryIcon: getIcon = getCategoryIcon,
}) => {
const desc = getLabel(node.description, language);
return (
<div
className={styles.nodeItem}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/json', JSON.stringify({ type: node.id }));
e.dataTransfer.effectAllowed = 'copy';
}}
>
<div
className={styles.nodeItemIcon}
style={{
backgroundColor: node.meta?.color
? `${node.meta.color}20`
: 'var(--bg-tertiary, #e9ecef)',
color: node.meta?.color ?? 'var(--text-secondary, #666)',
}}
>
{getIcon(node.category)}
</div>
<div className={styles.nodeItemInfo}>
<span className={styles.nodeItemLabel}>{getLabel(node.label, language)}</span>
<span className={styles.nodeItemDesc}>{desc}</span>
</div>
{desc && <div className={styles.nodeItemTooltip}>{desc}</div>}
</div>
);
};

View file

@ -5,12 +5,14 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa'; import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../../api/automation2Api'; import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants'; import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
import { getLabel } from '../nodes/shared/utils'; import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem'; import { NodeListItem } from './NodeListItem';
import styles from './Automation2FlowEditor.module.css'; import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface NodeSidebarProps { interface NodeSidebarProps {
nodeTypes: NodeType[]; nodeTypes: NodeType[];
categories: NodeTypeCategory[]; categories: NodeTypeCategory[];
@ -21,10 +23,10 @@ interface NodeSidebarProps {
onToggleCategory: (id: string) => void; onToggleCategory: (id: string) => void;
/** Hide palette categories (e.g. trigger — start node comes from workflow config only) */ /** Hide palette categories (e.g. trigger — start node comes from workflow config only) */
excludedCategories?: Set<string>; excludedCategories?: Set<string>;
style?: React.CSSProperties;
} }
export const NodeSidebar: React.FC<NodeSidebarProps> = ({ export const NodeSidebar: React.FC<NodeSidebarProps> = ({ nodeTypes,
nodeTypes,
categories, categories,
filter, filter,
onFilterChange, onFilterChange,
@ -32,7 +34,9 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
expandedCategories, expandedCategories,
onToggleCategory, onToggleCategory,
excludedCategories, excludedCategories,
style,
}) => { }) => {
const { t } = useLanguage();
const filteredNodeTypes = useMemo(() => { const filteredNodeTypes = useMemo(() => {
const visible = nodeTypes.filter( const visible = nodeTypes.filter(
(n) => (n) =>
@ -74,17 +78,17 @@ export const NodeSidebar: React.FC<NodeSidebarProps> = ({
return result; return result;
}, [groupedByCategory]); }, [groupedByCategory]);
const getLabelFn = (t: string | Record<string, string> | undefined, lang?: string) => const getLabelFn = (multilingual: string | Record<string, string> | undefined, lang?: string) =>
getLabel(t, lang ?? language); getLabel(multilingual, lang ?? language);
return ( return (
<div className={styles.sidebar}> <div className={styles.sidebar} style={style}>
<div className={styles.sidebarHeader}> <div className={styles.sidebarHeader}>
<h3 className={styles.sidebarTitle}>Nodes</h3> <h3 className={styles.sidebarTitle}>{t('Knoten')}</h3>
<input <input
type="text" type="text"
className={styles.sidebarSearch} className={styles.sidebarSearch}
placeholder="Nodes durchsuchen..." placeholder={t('Nodes durchsuchen')}
value={filter} value={filter}
onChange={(e) => onFilterChange(e.target.value)} onChange={(e) => onFilterChange(e.target.value)}
/> />

View file

@ -0,0 +1,263 @@
/**
* RunTracingPanel
*
* Shows AutoStepLog entries for a workflow run with live SSE push.
* Falls back to polling if SSE connection fails.
* Displays per-node status, timing, I/O snapshots, and retry info.
*/
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
import api from '../../../api';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface RunTracingPanelProps {
instanceId: string;
runId: string | null;
onNodeSelect?: (nodeId: string) => void;
onActiveStepsChange?: (nodeStatuses: Record<string, string>) => void;
}
const STATUS_COLORS: Record<string, string> = {
pending: '#999',
running: '#f0ad4e',
completed: '#28a745',
failed: '#dc3545',
skipped: '#6c757d',
};
const STATUS_ICONS: Record<string, string> = {
pending: '○',
running: '◉',
completed: '✓',
failed: '✗',
skipped: '—',
};
function _formatTimestamp(ts: number | string | null | undefined): string {
if (!ts) return '';
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts);
if (isNaN(d.getTime())) return '';
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function _truncateJson(obj: unknown, maxLen = 300): string {
if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return '';
try {
const s = JSON.stringify(obj, null, 2);
return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s;
} catch {
return String(obj);
}
}
const CollapsibleSection: React.FC<{
label: string; content: string;
}> = ({ label, content }) => {
const [open, setOpen] = useState(false);
if (!content) return null;
return (
<div style={{ marginTop: '4px' }}>
<button
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
color: 'var(--text-link, #0969da)', fontSize: '11px', textDecoration: 'underline',
}}
>
{open ? '▾' : '▸'} {label}
</button>
{open && (
<pre style={{
margin: '4px 0 0', padding: '6px', borderRadius: '4px',
background: 'var(--bg-secondary, #f6f8fa)', fontSize: '11px',
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: '200px', overflowY: 'auto',
}}>
{content}
</pre>
)}
</div>
);
};
export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
instanceId,
runId,
onNodeSelect,
onActiveStepsChange,
}) => {
const { t } = useLanguage();
const [steps, setSteps] = useState<AutoStepLog[]>([]);
const [loading, setLoading] = useState(false);
const [sseConnected, setSseConnected] = useState(false);
const eventSourceRef = useRef<EventSource | null>(null);
const { request } = useApiRequest();
const loadSteps = useCallback(async () => {
if (!runId || !instanceId) return;
setLoading(true);
try {
const data = await request({
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
method: 'get',
});
setSteps(data?.steps || []);
} catch (e) {
console.error('[RunTracing] Failed to load steps:', e);
} finally {
setLoading(false);
}
}, [runId, instanceId, request]);
// SSE live-push connection
useEffect(() => {
if (!runId || !instanceId) return;
loadSteps();
const baseUrl = api.defaults.baseURL || '';
const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`;
const es = new EventSource(url, { withCredentials: true });
eventSourceRef.current = es;
es.onopen = () => setSseConnected(true);
es.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === 'keepalive') return;
if (payload.type === 'run_complete' || payload.type === 'run_failed') {
loadSteps();
es.close();
setSseConnected(false);
return;
}
if (payload.status === 'running') {
setSteps((prev) => {
const exists = prev.some((s) => s.id === payload.id);
if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s);
return [...prev, payload as AutoStepLog];
});
} else {
setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s));
}
} catch { /* ignore parse errors */ }
};
es.onerror = () => {
setSseConnected(false);
es.close();
};
return () => {
es.close();
eventSourceRef.current = null;
setSseConnected(false);
};
}, [runId, instanceId]); // eslint-disable-line react-hooks/exhaustive-deps
// Fallback polling when SSE is not connected
useEffect(() => {
if (sseConnected || !runId || !instanceId) return;
const interval = setInterval(loadSteps, 3000);
return () => clearInterval(interval);
}, [sseConnected, runId, instanceId, loadSteps]);
// Emit active node statuses for canvas highlighting
useEffect(() => {
if (!onActiveStepsChange) return;
const nodeStatuses: Record<string, string> = {};
for (const step of steps) {
nodeStatuses[step.nodeId] = step.status;
}
onActiveStepsChange(nodeStatuses);
}, [steps, onActiveStepsChange]);
if (!runId) {
return (
<div style={{ padding: '16px', color: 'var(--text-secondary, #888)', fontSize: '13px' }}>
{t('Run auswählen, um Tracing-Details zu sehen.')}
</div>
);
}
return (
<div style={{ padding: '12px', overflowY: 'auto', height: '100%' }}>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '12px' }}>
{t('Run-Schritte')}{' '}
{loading && (
<span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>({t('wird geladen…')})</span>
)}
</div>
{steps.length === 0 && !loading && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>{t('Noch keine Schritte aufgezeichnet')}</div>
)}
{steps.map((step: any) => {
const startStr = _formatTimestamp(step.startedAt);
const endStr = _formatTimestamp(step.completedAt);
const inputStr = _truncateJson(step.inputSnapshot);
const outputStr = _truncateJson(step.output);
const isLoop = step.inputSnapshot?._loopIndex != null;
return (
<div
key={step.id}
onClick={() => onNodeSelect?.(step.nodeId)}
style={{
padding: '8px 12px',
marginBottom: '6px',
borderRadius: '6px',
border: `1px solid ${STATUS_COLORS[step.status] || '#ddd'}`,
background: 'var(--bg-primary, #fff)',
cursor: 'pointer',
fontSize: '13px',
marginLeft: isLoop ? '16px' : '0',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<span style={{ color: STATUS_COLORS[step.status] || '#999', marginRight: '6px' }}>
{STATUS_ICONS[step.status] || '?'}
</span>
<strong>{step.nodeType}</strong>
<span style={{ color: '#888', marginLeft: '6px' }}>({step.nodeId})</span>
{isLoop && (
<span style={{ color: '#666', marginLeft: '6px', fontSize: '11px' }}>
[iter {step.inputSnapshot._loopIndex}]
</span>
)}
</span>
<span style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{step.retryCount > 0 && (
<span style={{ color: '#f0ad4e', fontSize: '11px' }} title={t('Wiederholungsanzahl')}>
{step.retryCount}x {t('Wiederholung')}
</span>
)}
{step.durationMs != null && (
<span style={{ color: '#888', fontSize: '12px' }}>{step.durationMs}ms</span>
)}
</span>
</div>
{(startStr || endStr) && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
{startStr && <span>{startStr}</span>}
{startStr && endStr && <span> </span>}
{endStr && <span>{endStr}</span>}
</div>
)}
{step.error && (
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
)}
{step.tokensUsed > 0 && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>
{step.tokensUsed} {t('Tokens')}
</div>
)}
<CollapsibleSection label={t('Eingabe')} content={inputStr} />
<CollapsibleSection label={t('Ausgabe')} content={outputStr} />
</div>
);
})}
</div>
);
};

View file

@ -0,0 +1,155 @@
/**
* TemplatePicker - modal to browse and select a workflow template for creating a new workflow.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSpinner } from 'react-icons/fa';
import {
fetchTemplates,
type AutoWorkflowTemplate,
type AutoTemplateScope,
type ApiRequestFunction,
} from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface TemplatePickerProps {
open: boolean;
onClose: () => void;
onSelect: (templateId: string) => void;
instanceId: string;
request: ApiRequestFunction;
}
export const TemplatePicker: React.FC<TemplatePickerProps> = ({
open,
onClose,
onSelect,
instanceId,
request,
}) => {
const { t } = useLanguage();
const scopeLabels = useMemo(
() =>
({
all: t('Alle'),
user: t('Meine'),
instance: t('Instanz'),
mandate: t('Mandant'),
system: t('System'),
}) as Record<AutoTemplateScope | 'all', string>,
[t]
);
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
const [loading, setLoading] = useState(false);
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
const [copying, setCopying] = useState<string | null>(null);
const _load = useCallback(async () => {
if (!instanceId || !open) return;
setLoading(true);
try {
const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, instanceId, scope);
setTemplates(Array.isArray(result) ? result : result.items);
} catch {
setTemplates([]);
} finally {
setLoading(false);
}
}, [instanceId, request, open, activeScope]);
useEffect(() => {
_load();
}, [_load]);
const _handleSelect = useCallback(
async (templateId: string) => {
setCopying(templateId);
try {
await onSelect(templateId);
} finally {
setCopying(null);
}
},
[onSelect]
);
if (!open) return null;
return (
<div className={styles.workflowModalBackdrop} role="dialog" aria-modal="true" aria-labelledby="tpl-picker-title">
<div className={styles.workflowModal} style={{ maxWidth: 600, maxHeight: '80vh', display: 'flex', flexDirection: 'column' }}>
<h3 id="tpl-picker-title" className={styles.workflowModalTitle}>
{t('Neu aus Vorlage')}
</h3>
<p className={styles.workflowModalHint}>
{t('Wählen Sie eine Vorlage, um einen neuen Workflow zu erstellen.')}
</p>
<div style={{ display: 'flex', gap: 6, marginBottom: 12, flexWrap: 'wrap' }}>
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
<button
key={s}
type="button"
className={activeScope === s ? styles.workflowModalBtnPrimary : styles.workflowModalBtnSecondary}
onClick={() => setActiveScope(s)}
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
>
{scopeLabels[s]}
</button>
))}
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 120 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: 24 }}>
<FaSpinner className={styles.spinner} />
</div>
) : templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: 24, color: 'var(--text-secondary, #888)' }}>
{t('Keine Vorlagen gefunden.')}
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color, #e0e0e0)', textAlign: 'left' }}>
<th style={{ padding: '6px 8px' }}>{t('Name')}</th>
<th style={{ padding: '6px 8px', width: 80 }}>{t('Scope')}</th>
<th style={{ padding: '6px 8px', width: 100 }}></th>
</tr>
</thead>
<tbody>
{templates.map((tpl) => (
<tr key={tpl.id} style={{ borderBottom: '1px solid var(--border-color, #eee)' }}>
<td style={{ padding: '8px' }}>{tpl.label}</td>
<td style={{ padding: '8px', fontSize: '0.8rem', color: 'var(--text-secondary, #888)' }}>
{scopeLabels[(tpl.templateScope as AutoTemplateScope) || 'user']}
</td>
<td style={{ padding: '8px', textAlign: 'right' }}>
<button
type="button"
className={styles.workflowModalBtnPrimary}
style={{ fontSize: '0.8rem', padding: '4px 10px' }}
onClick={() => _handleSelect(tpl.id)}
disabled={copying !== null}
>
{copying === tpl.id ? <FaSpinner className={styles.spinner} /> : t('Übernehmen')}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className={styles.workflowModalActions} style={{ marginTop: 12 }}>
<button type="button" className={styles.workflowModalBtnSecondary} onClick={onClose}>
{t('Abbrechen')}
</button>
</div>
</div>
</div>
);
};

View file

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

View file

@ -1,4 +1,5 @@
export { Automation2FlowEditor } from './editor/Automation2FlowEditor'; export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './editor/EditorChatPanel';
export { FlowCanvas } from './editor/FlowCanvas'; export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas'; export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel'; export { NodeConfigPanel } from './editor/NodeConfigPanel';
@ -8,4 +9,4 @@ export { CanvasHeader } from './editor/CanvasHeader';
export * from './nodes/shared/utils'; export * from './nodes/shared/utils';
export * from './nodes/shared/constants'; export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils'; export * from './nodes/shared/graphUtils';
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig'; export { getAcceptStringFromConfig } from './nodes/shared/utils';

View file

@ -4,16 +4,18 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa'; import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../configs/types'; import type { FormField, NodeConfigRendererProps } from '../shared/types';
import { fetchConnections, type UserConnection } from '../../../../api/automation2Api'; import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ import { useLanguage } from '../../../../providers/language/LanguageContext';
params,
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam, updateParam,
instanceId, instanceId,
request, request,
}) => { }) => {
const { t } = useLanguage();
const fields = (params.fields as FormField[]) ?? []; const fields = (params.fields as FormField[]) ?? [];
const [connections, setConnections] = useState<UserConnection[]>([]); const [connections, setConnections] = useState<UserConnection[]>([]);
const [connectionsLoading, setConnectionsLoading] = useState(false); const [connectionsLoading, setConnectionsLoading] = useState(false);
@ -55,7 +57,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
return ( return (
<div> <div>
<label>Felder</label> <label>{t('Felder')}</label>
<div className={styles.formFieldsList}> <div className={styles.formFieldsList}>
{fields.map((f, i) => ( {fields.map((f, i) => (
<div <div
@ -74,7 +76,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
<div className={styles.formFieldRowHeader}> <div className={styles.formFieldRowHeader}>
<span <span
className={styles.formFieldDragHandle} className={styles.formFieldDragHandle}
title="Zum Verschieben ziehen" title={t('Zum Verschieben ziehen')}
draggable draggable
onDragStart={(e) => { onDragStart={(e) => {
e.dataTransfer.setData('text/plain', String(i)); e.dataTransfer.setData('text/plain', String(i));
@ -85,7 +87,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
</span> </span>
<div className={styles.formFieldInputs}> <div className={styles.formFieldInputs}>
<input <input
placeholder="name" placeholder={t('name')}
value={f.name ?? ''} value={f.name ?? ''}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
@ -94,7 +96,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
}} }}
/> />
<input <input
placeholder="label" placeholder={t('label')}
value={f.label ?? ''} value={f.label ?? ''}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
@ -109,13 +111,13 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
value={f.type ?? 'string'} value={f.type ?? 'string'}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
const t = e.target.value; const fieldType = e.target.value;
next[i] = { next[i] = {
...next[i], ...next[i],
type: t, type: fieldType,
...(t === 'clickup_tasks' ...(fieldType === 'clickup_tasks'
? { clickupStatusOptions: undefined } ? { clickupStatusOptions: undefined }
: t === 'clickup_status' : fieldType === 'clickup_status'
? { clickupConnectionId: undefined, clickupListId: undefined } ? { clickupConnectionId: undefined, clickupListId: undefined }
: { : {
clickupConnectionId: undefined, clickupConnectionId: undefined,
@ -127,12 +129,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
}} }}
style={{ width: 'auto', minWidth: 90 }} style={{ width: 'auto', minWidth: 90 }}
> >
<option value="string">Text</option> <option value="string">{t('Text')}</option>
<option value="number">Number</option> <option value="number">{t('Zahl')}</option>
<option value="date">Date</option> <option value="date">{t('Datum')}</option>
<option value="boolean">Checkbox</option> <option value="boolean">{t('Kontrollkästchen')}</option>
<option value="clickup_tasks">ClickUp-Aufgabe (Referenz)</option> <option value="clickup_tasks">{t('ClickUp-Aufgabe Referenz')}</option>
<option value="clickup_status">ClickUp-Status (Liste)</option> <option value="clickup_status">{t('ClickUp-Status Liste')}</option>
</select> </select>
<label className={styles.formFieldRequiredLabel}> <label className={styles.formFieldRequiredLabel}>
<input <input
@ -144,12 +146,12 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
updateParam('fields', next); updateParam('fields', next);
}} }}
/> />
Pflichtfeld {t('Pflichtfeld')}
</label> </label>
<button <button
type="button" type="button"
onClick={() => removeField(i)} onClick={() => removeField(i)}
title="Feld entfernen" title={t('Feld entfernen')}
className={styles.formFieldRemoveButton} className={styles.formFieldRemoveButton}
> >
<FaTimes /> <FaTimes />
@ -159,13 +161,16 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}> <div style={{ marginTop: 8, paddingLeft: 4, width: '100%', fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>
{Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? ( {Array.isArray(f.clickupStatusOptions) && f.clickupStatusOptions.length > 0 ? (
<p style={{ margin: '0 0 6px' }}> <p style={{ margin: '0 0 6px' }}>
Dropdown mit {f.clickupStatusOptions.length} Status aus der ClickUp-Liste (Wert = exakter {t(
Status-Name für die API). 'Dropdown mit {count} Status aus der ClickUp-Liste (Wert = exakter Status-Name für die API).',
{ count: String(f.clickupStatusOptions.length) }
)}
</p> </p>
) : ( ) : (
<p style={{ margin: '0 0 6px' }}> <p style={{ margin: '0 0 6px' }}>
Keine Optionen im ClickUp-Knoten Aufgabe erstellen Liste wählen und Formular mit Liste {t(
abgleichen. 'Keine Optionen — im ClickUp-Knoten „Aufgabe erstellen“ Liste wählen und „Formular mit Liste abgleichen“.'
)}
</p> </p>
)} )}
</div> </div>
@ -173,7 +178,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
{f.type === 'clickup_tasks' ? ( {f.type === 'clickup_tasks' ? (
<div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}> <div style={{ marginTop: 8, paddingLeft: 4, width: '100%' }}>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}> <label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
ClickUp-Verbindung {t('ClickUp-Verbindung')}
</label> </label>
<select <select
value={f.clickupConnectionId ?? ''} value={f.clickupConnectionId ?? ''}
@ -185,7 +190,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
disabled={connectionsLoading || !instanceId} disabled={connectionsLoading || !instanceId}
style={{ width: '100%', marginBottom: 8 }} style={{ width: '100%', marginBottom: 8 }}
> >
<option value="">{connectionsLoading ? 'Lade…' : 'Verbindung wählen…'}</option> <option value="">{connectionsLoading ? t('Lade…') : t('Verbindung wählen…')}</option>
{connections.map((c) => ( {connections.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.externalUsername ?? c.id} {c.externalUsername ?? c.id}
@ -193,10 +198,10 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
))} ))}
</select> </select>
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}> <label style={{ display: 'block', fontSize: '0.8rem', marginBottom: 4 }}>
Listen-ID (verknüpfte Liste / Ziel-Liste) {t('Listen-ID (verknüpfte Liste / Ziel-Liste)')}
</label> </label>
<input <input
placeholder="z. B. aus ClickUp-URL …/list/123456789" placeholder={t('z.B. aus ClickUp-URL: list/123456789')}
value={f.clickupListId ?? ''} value={f.clickupListId ?? ''}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
@ -206,9 +211,9 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
<p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}> <p style={{ fontSize: '0.75rem', color: 'var(--text-secondary, #666)', marginTop: 6 }}>
Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:{' '} {t('Liefert beim Ausfüllen denselben Wert wie ein ClickUp-Relationship-Feld:')}{' '}
<code>{'{ add: [taskId], rem: [] }'}</code> im ClickUp-Node per Datenquelle auf das <code>{'{ add: [taskId], rem: [] }'}</code>{' '}
Formularfeld mappen. {t('— im ClickUp-Node per Datenquelle auf das Formularfeld mappen.')}
</p> </p>
</div> </div>
) : null} ) : null}
@ -220,7 +225,7 @@ export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }]) updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
} }
> >
+ Feld + {t('Feld')}
</button> </button>
</div> </div>
</div> </div>

View file

@ -0,0 +1,418 @@
/**
* Generic FrontendType renderer registry.
* Maps frontendType strings to React components.
*/
import type { ComponentType } from 'react';
import type { NodeTypeParameter } from '../../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
export interface FieldRendererProps {
param: NodeTypeParameter;
value: unknown;
onChange: (value: unknown) => void;
allParams?: Record<string, unknown>;
instanceId?: string;
request?: ApiRequestFunction;
nodeType?: string;
}
export type FieldRendererComponent = ComponentType<FieldRendererProps>;
// ---------------------------------------------------------------------------
// Inline renderers for standard types
// ---------------------------------------------------------------------------
import React from 'react';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
/>
</div>
);
const TextareaInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<textarea
value={typeof value === 'string' ? value : (value != null ? String(value) : '')}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
rows={4}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', resize: 'vertical' }}
/>
</div>
);
const NumberInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="number"
value={typeof value === 'number' ? value : (value != null ? Number(value) || 0 : '')}
onChange={(e) => onChange(e.target.value ? Number(e.target.value) : undefined)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
/>
</div>
);
const CheckboxInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
/>
<label style={{ fontSize: 12 }}>{param.description || param.name}</label>
</div>
);
const DateInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="date"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
/>
</div>
);
const SelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value=""></option>
{options.map((opt) => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
);
};
const MultiSelectInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const options: string[] =
(param.frontendOptions?.options as string[]) || (param.options as string[]) || [];
const selected = Array.isArray(value) ? value : [];
const toggle = (opt: string) => {
const next = selected.includes(opt) ? selected.filter((v: string) => v !== opt) : [...selected, opt];
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{options.map((opt) => (
<label key={opt} style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="checkbox" checked={selected.includes(opt)} onChange={() => toggle(opt)} />
{opt}
</label>
))}
</div>
</div>
);
};
const JsonEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const strVal = typeof value === 'string' ? value : JSON.stringify(value ?? '', null, 2);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<textarea
value={strVal}
onChange={(e) => {
try { onChange(JSON.parse(e.target.value)); } catch { onChange(e.target.value); }
}}
rows={6}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace', fontSize: 11, resize: 'vertical' }}
/>
</div>
);
};
const HiddenInput: React.FC<FieldRendererProps> = () => null;
const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const [connections, setConnections] = React.useState<Array<{ id: string; label: string }>>([]);
React.useEffect(() => {
if (!instanceId || !request) return;
request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' })
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
})
.catch(() => {});
}, [instanceId, request]);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<select
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc' }}
>
<option value="">{t('Verbindung wählen')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>{c.label}</option>
))}
</select>
</div>
);
};
const FolderPicker: React.FC<FieldRendererProps> = ({ param, value, onChange, allParams }) => {
const { t } = useLanguage();
const dependsOn = param.frontendOptions?.dependsOn as string | undefined;
const depValue = dependsOn ? allParams?.[dependsOn] : undefined;
const disabled = dependsOn && !depValue;
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
disabled={!!disabled}
placeholder={disabled ? t('Zuerst {field} wählen', { field: dependsOn ?? '' }) : param.name}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: disabled ? 0.5 : 1 }}
/>
</div>
);
};
const CaseListEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cases = Array.isArray(value) ? value : [];
const addCase = () => onChange([...cases, { operator: 'eq', value: '' }]);
const removeCase = (idx: number) => onChange(cases.filter((_: unknown, i: number) => i !== idx));
const updateCase = (idx: number, field: string, val: unknown) => {
const next = [...cases];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{cases.map((c: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<select value={String(c.operator || 'eq')} onChange={(e) => updateCase(i, 'operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">{t('enthält')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
<input type="text" value={String(c.value ?? '')} onChange={(e) => updateCase(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeCase(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addCase} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Fall hinzufügen')}</button>
</div>
);
};
const FieldBuilderEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
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));
const updateField = (idx: number, field: string, val: unknown) => {
const next = [...fields];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{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>
</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')}
</label>
<button onClick={() => removeField(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addField} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Feld hinzufügen')}</button>
</div>
);
};
const KeyValueRowsEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const rows = Array.isArray(value) ? value : [];
const addRow = () => onChange([...rows, { key: '', value: '' }]);
const removeRow = (idx: number) => onChange(rows.filter((_: unknown, i: number) => i !== idx));
const updateRow = (idx: number, field: string, val: string) => {
const next = [...rows];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{rows.map((r: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input type="text" placeholder={t('Schlüssel')} value={String(r.key ?? r.fieldKey ?? '')} onChange={(e) => updateRow(i, 'key', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<input type="text" placeholder={t('Wert')} value={String(r.value ?? '')} onChange={(e) => updateRow(i, 'value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeRow(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addRow} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Zeile hinzufügen')}</button>
</div>
);
};
const CronBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<input
type="text"
value={typeof value === 'string' ? value : ''}
onChange={(e) => onChange(e.target.value)}
placeholder={t('0 9 * * *')}
style={{ width: '100%', padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontFamily: 'monospace' }}
/>
<p style={{ fontSize: 10, color: '#888', margin: '2px 0 0' }}>{t('Cron: Min Stunde Tag Monat')}</p>
</div>
);
};
const ConditionBuilder: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, type: 'condition', [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
<option value="contains">{t('enthält')}</option>
<option value="empty">{t('ist leer')}</option>
<option value="not_empty">{t('ist nicht leer')}</option>
<option value="is_true">{t('ist wahr')}</option>
<option value="is_false">{t('ist falsch')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 2, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
const MappingTableEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const mappings = Array.isArray(value) ? value : [];
const addMapping = () => onChange([...mappings, { sourceField: '', outputField: '' }]);
const removeMapping = (idx: number) => onChange(mappings.filter((_: unknown, i: number) => i !== idx));
const updateMapping = (idx: number, field: string, val: string) => {
const next = [...mappings];
next[idx] = { ...(next[idx] as Record<string, unknown>), [field]: val };
onChange(next);
};
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
{mappings.map((m: Record<string, unknown>, i: number) => (
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
<input type="text" placeholder={t('Quellfeld')} value={String(m.sourceField ?? '')} onChange={(e) => updateMapping(i, 'sourceField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<span style={{ alignSelf: 'center' }}></span>
<input type="text" placeholder={t('Ausgabefeld')} value={String(m.outputField ?? '')} onChange={(e) => updateMapping(i, 'outputField', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<button onClick={() => removeMapping(i)} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer' }}>×</button>
</div>
))}
<button onClick={addMapping} style={{ padding: '2px 8px', borderRadius: 4, border: '1px solid #ccc', cursor: 'pointer', fontSize: 12 }}>{t('Zuordnung hinzufügen')}</button>
</div>
);
};
const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cond = (typeof value === 'object' && value !== null) ? value as Record<string, unknown> : {};
const update = (field: string, val: unknown) => onChange({ ...cond, [field]: val });
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" placeholder={t('Feld')} value={String(cond.field ?? '')} onChange={(e) => update('field', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
<select value={String(cond.operator ?? 'eq')} onChange={(e) => update('operator', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }}>
<option value="eq">{t('ist gleich')}</option>
<option value="neq">{t('ungleich')}</option>
<option value="contains">{t('enthält')}</option>
<option value="startsWith">{t('beginnt mit')}</option>
<option value="isEmpty">{t('ist leer')}</option>
<option value="isNotEmpty">{t('ist nicht leer')}</option>
<option value="gt">{t('größer als')}</option>
<option value="lt">{t('kleiner als')}</option>
</select>
<input type="text" placeholder={t('Wert')} value={String(cond.value ?? '')} onChange={(e) => update('value', e.target.value)} style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc' }} />
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
text: TextInput,
textarea: TextareaInput,
number: NumberInput,
checkbox: CheckboxInput,
date: DateInput,
datetime: DateInput,
email: TextInput,
select: SelectInput,
multiselect: MultiSelectInput,
json: JsonEditor,
file: TextInput,
hidden: HiddenInput,
userConnection: ConnectionPicker,
sharepointFolder: FolderPicker,
sharepointFile: FolderPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
fieldBuilder: FieldBuilderEditor,
keyValueRows: KeyValueRowsEditor,
cron: CronBuilder,
condition: ConditionBuilder,
mappingTable: MappingTableEditor,
filterExpression: FilterExpressionEditor,
};
export default FRONTEND_TYPE_RENDERERS;

View file

@ -4,7 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect'; import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef } from '../shared/dataRef'; import { isRef } from '../shared/dataRef';
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
import { operatorsForType } from '../shared/conditionOperators'; import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface StructuredCondition { export interface StructuredCondition {
type: 'condition'; type: 'condition';
ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null; ref: { type: 'ref'; nodeId: string; path: (string | number)[] } | null;
@ -28,6 +30,7 @@ function parseCondition(v: unknown): StructuredCondition | null {
} }
export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
const cond = parseCondition(params.condition); const cond = parseCondition(params.condition);
@ -96,8 +99,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
return ( return (
<div className={styles.ifElseConditionEditor}> <div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Datenquelle</label> <label>{t('Datenquelle')}</label>
<RefSourceSelect value={ref} onChange={handleRefChange} placeholder="Formular-Feld wählen…" /> <RefSourceSelect value={ref} onChange={handleRefChange} placeholder={t('Formularfeld wählen')} />
</div> </div>
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Vergleich</label> <label>Vergleich</label>
@ -111,13 +114,13 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
</div> </div>
{needsValue && ( {needsValue && (
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Wert</label> <label>{t('Wert')}</label>
{mimeTypeOptions.length > 0 ? ( {mimeTypeOptions.length > 0 ? (
<select <select
value={String(value ?? '')} value={String(value ?? '')}
onChange={(e) => handleValueChange(e.target.value)} onChange={(e) => handleValueChange(e.target.value)}
> >
<option value=""> MIME-Type wählen </option> <option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => ( {mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} ({o.value}) {o.label} ({o.value})
@ -139,8 +142,8 @@ export const IfElseNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
: fieldType === 'date' : fieldType === 'date'
? 'TT.MM.JJJJ' ? 'TT.MM.JJJJ'
: isMimeTypeRef : isMimeTypeRef
? 'z.B. application/pdf' ? t('z.B. application/pdf')
: 'z.B. CH' : t('z.B. ch')
} }
/> />
)} )}

View file

@ -4,7 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { LoopItemsSelect } from '../shared/LoopItemsSelect'; import { LoopItemsSelect } from '../shared/LoopItemsSelect';
import { createValue, isRef, isValue } from '../shared/dataRef'; import { createValue, isRef, isValue } from '../shared/dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';

View file

@ -3,8 +3,8 @@
*/ */
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType } from '../../../../api/automation2Api'; import type { NodeType } from '../../../../api/workflowApi';
import type { WorkflowEntryPoint } from '../../../../api/automation2Api'; import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
import { getLabel } from '../shared/utils'; import { getLabel } from '../shared/utils';
export const CANVAS_START_NODE_ID = 'start'; export const CANVAS_START_NODE_ID = 'start';

View file

@ -0,0 +1,251 @@
/**
* Automation2 Flow Editor - Schema-based Data Picker.
* Builds pickable paths from portTypeCatalog + node outputPorts.
* 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 { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import type { NodeType, PortSchema } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
interface DataPickerProps {
open: boolean;
onClose: () => void;
onPick: (ref: DataRef | SystemVarRef) => void;
availableSourceIds: string[];
nodes: Array<{ id: string; title?: string; type?: string }>;
nodeOutputsPreview: Record<string, unknown>;
getNodeLabel: (node: { id: string; title?: string }) => string;
}
interface PickablePath {
path: (string | number)[];
label: string;
type?: string;
}
function _buildPathsFromSchema(
schema: PortSchema | undefined,
basePath: (string | number)[] = [],
): PickablePath[] {
if (!schema || !schema.fields) 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 });
}
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' });
return result;
}
function _buildPathsFromPreview(
obj: unknown,
basePath: (string | number)[] = [],
wholeOutputLabel = '(ganze Ausgabe)',
): PickablePath[] {
const pathLabel = basePath.length ? basePath.map(String).join(' → ') : wholeOutputLabel;
if (obj == null || typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
return [{ path: [...basePath], label: pathLabel }];
}
if (Array.isArray(obj)) {
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
for (let i = 0; i < Math.min(obj.length, 5); i++) {
result.push(..._buildPathsFromPreview(obj[i], [...basePath, i], wholeOutputLabel));
}
return result;
}
if (typeof obj === 'object') {
const result: PickablePath[] = [{ path: [...basePath], label: pathLabel }];
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (k.startsWith('_')) continue;
result.push(..._buildPathsFromPreview(v, [...basePath, k], wholeOutputLabel));
}
return result;
}
return [{ path: [...basePath], label: pathLabel }];
}
function _resolveSchemaForNode(
nodeId: string,
nodes: Array<{ id: string; type?: string }>,
nodeTypes: NodeType[],
connections: Array<{ source: string; target: string; sourceOutput?: number }>,
catalog: Record<string, PortSchema>,
visited: Set<string> = new Set(),
): PortSchema | undefined {
if (visited.has(nodeId)) return undefined;
visited.add(nodeId);
const node = nodes.find((n) => n.id === nodeId);
if (!node) return undefined;
const typeDef = nodeTypes.find((nt) => nt.id === node.type);
if (!typeDef?.outputPorts) return undefined;
const port0 = typeDef.outputPorts[0];
if (!port0) return undefined;
if (port0.schema !== 'Transit') {
return catalog[port0.schema];
}
// 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);
}
export const DataPicker: React.FC<DataPickerProps> = ({ open,
onClose,
onPick,
availableSourceIds,
nodes,
nodeOutputsPreview,
getNodeLabel,
}) => {
const { t } = useLanguage();
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [showSystem, setShowSystem] = useState(false);
const ctx = useAutomation2DataFlow();
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 toggleExpand = (nodeId: string) => {
setExpandedNodes((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
const handlePick = (nodeId: string, path: (string | number)[]) => {
onPick(createRef(nodeId, path));
onClose();
};
const handlePickSystemVar = (variable: string) => {
onPick(createSystemVar(variable));
onClose();
};
return (
<div className={styles.dataPickerOverlay} onClick={onClose}>
<div className={styles.dataPickerModal} onClick={(e) => e.stopPropagation()}>
<div className={styles.dataPickerHeader}>
<h4 className={styles.dataPickerTitle}>{t('Datenquelle wählen')}</h4>
<button type="button" className={styles.dataPickerClose} onClick={onClose} aria-label={t('Schließen')}>
×
</button>
</div>
<div className={styles.dataPickerBody}>
{/* System Variables Section */}
{Object.keys(systemVars).length > 0 && (
<div className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
onClick={() => setShowSystem(!showSystem)}
>
<span className={styles.dataPickerExpandIcon}>{showSystem ? '▼' : '▶'}</span>
<span className={styles.dataPickerNodeLabel}>{t('System')}</span>
</button>
{showSystem && (
<div className={styles.dataPickerTree}>
{Object.entries(systemVars).map(([key, info]) => (
<button
key={key}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePickSystemVar(key)}
title={info.description}
>
{key} <span style={{ color: '#888', fontSize: 10 }}>({info.type})</span>
</button>
))}
</div>
)}
</div>
)}
{/* Node outputs */}
{(() => {
const filteredIds = availableSourceIds.filter((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
return node?.type !== 'trigger.manual';
});
if (filteredIds.length === 0 && Object.keys(systemVars).length === 0) {
return <p className={styles.dataPickerEmpty}>{t('Keine vorherigen Nodes verfügbar')}</p>;
}
return filteredIds.map((nodeId) => {
const node = nodes.find((n) => n.id === nodeId);
const label = node ? getNodeLabel(node) : nodeId;
const isExpanded = expandedNodes.has(nodeId);
const resolvedSchema = _resolveSchemaForNode(
nodeId, nodes, nodeTypes, connections, catalog,
);
const schemaPaths = _buildPathsFromSchema(resolvedSchema);
const paths = schemaPaths.length > 0
? schemaPaths
: _buildPathsFromPreview(nodeOutputsPreview[nodeId], [], t('(ganze Ausgabe)'));
return (
<div key={nodeId} className={styles.dataPickerNodeSection}>
<button
type="button"
className={styles.dataPickerNodeHeader}
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>
)}
</button>
{isExpanded && (
<div className={styles.dataPickerTree}>
{paths.map((p, i) => (
<button
key={`${p.path.join('.')}-${i}`}
type="button"
className={styles.dataPickerLeaf}
onClick={() => handlePick(nodeId, p.path)}
>
{p.label}
{p.type && (
<span style={{ color: '#888', fontSize: 10, marginLeft: 4 }}>
({p.type})
</span>
)}
</button>
))}
</div>
)}
</div>
);
});
})()}
</div>
</div>
</div>
);
};

View file

@ -8,12 +8,15 @@ import {
createValue, createValue,
formatRefLabel, formatRefLabel,
type DataRef, type DataRef,
type SystemVarRef,
} from './dataRef'; } from './dataRef';
import { RefSourceSelect } from './RefSourceSelect'; import { RefSourceSelect } from './RefSourceSelect';
import { DataPicker } from './DataPicker'; import { DataPicker } from './DataPicker';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export type FieldType = 'textarea' | 'input'; export type FieldType = 'textarea' | 'input';
interface DynamicValueFieldProps { interface DynamicValueFieldProps {
@ -28,14 +31,14 @@ interface DynamicValueFieldProps {
variant?: 'picker' | 'dropdown'; variant?: 'picker' | 'dropdown';
} }
export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({ paramKey,
paramKey,
value, value,
onChange, onChange,
label, label,
placeholder, placeholder,
variant = 'picker', variant = 'picker',
}) => { }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
@ -47,7 +50,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
}); });
const canUseRef = dataFlow !== null && hasUsefulSources; const canUseRef = dataFlow !== null && hasUsefulSources;
const handleSetRef = (newRef: DataRef | null) => { const handleSetRef = (newRef: DataRef | SystemVarRef | null) => {
onChange(paramKey, newRef ?? createValue('')); onChange(paramKey, newRef ?? createValue(''));
}; };
@ -55,7 +58,7 @@ export const DynamicValueField: React.FC<DynamicValueFieldProps> = ({
return ( return (
<div className={styles.dynamicValueField}> <div className={styles.dynamicValueField}>
<label>{label}</label> <label>{label}</label>
<p className={styles.dynamicValueEmptyHint}>Keine vorherigen Nodes verfügbar.</p> <p className={styles.dynamicValueEmptyHint}>{t('Keine vorherigen Nodes verfügbar')}</p>
</div> </div>
); );
} }

View file

@ -13,6 +13,8 @@ import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext
import { isRef, isValue, createValue } from './dataRef'; import { isRef, isValue, createValue } from './dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
function parseHybrid(value: unknown): { staticStr: string } { function parseHybrid(value: unknown): { staticStr: string } {
if (isRef(value)) return { staticStr: '' }; if (isRef(value)) return { staticStr: '' };
if (isValue(value)) { if (isValue(value)) {
@ -37,8 +39,7 @@ export interface HybridStaticRefFieldProps {
pathPickMode?: PathPickMode; pathPickMode?: PathPickMode;
} }
export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({ label,
label,
value, value,
onChange, onChange,
multiline, multiline,
@ -46,6 +47,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
placeholder, placeholder,
pathPickMode = 'default', pathPickMode = 'default',
}) => { }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
const hasSources = const hasSources =
dataFlow && dataFlow &&
@ -83,7 +85,7 @@ export const HybridStaticRefField: React.FC<HybridStaticRefFieldProps> = ({
<StatischKontextSelect <StatischKontextSelect
value={value} value={value}
onChange={onChange} onChange={onChange}
placeholder="— Quelle wählen —" placeholder={t('Quelle wählen')}
pathPickMode={pathPickMode} pathPickMode={pathPickMode}
/> />
</div> </div>

View file

@ -9,6 +9,8 @@ import { refToOptionValue, optionValueToRef } from './RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
interface LoopOption { interface LoopOption {
ref: DataRef; ref: DataRef;
label: string; label: string;
@ -33,7 +35,8 @@ function buildLoopOptions(
sourceIds: string[], sourceIds: string[],
nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>, nodes: Array<{ id: string; type?: string; title?: string; parameters?: Record<string, unknown> }>,
nodeOutputsPreview: Record<string, unknown>, nodeOutputsPreview: Record<string, unknown>,
getNodeLabel: (n: { id: string; type?: string; title?: string }) => string getNodeLabel: (n: { id: string; type?: string; title?: string }) => string,
translate: (key: string) => string
): LoopOption[] { ): LoopOption[] {
const options: LoopOption[] = []; const options: LoopOption[] = [];
@ -48,13 +51,13 @@ function buildLoopOptions(
if (node?.type === 'trigger.form') { if (node?.type === 'trigger.form') {
options.push({ options.push({
ref: createRef(nodeId, ['payload']), ref: createRef(nodeId, ['payload']),
label: `Alle Formularfelder (${nodeLabel})`, label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
}); });
const filesVal = getValueAtPath(preview, ['files']); const filesVal = getValueAtPath(preview, ['files']);
if (Array.isArray(filesVal)) { if (Array.isArray(filesVal)) {
options.push({ options.push({
ref: createRef(nodeId, ['files']), ref: createRef(nodeId, ['files']),
label: `Alle Dateien aus Formular (${nodeLabel})`, label: `${translate('Alle Dateien aus Formular')} (${nodeLabel})`,
}); });
} }
continue; continue;
@ -63,7 +66,7 @@ function buildLoopOptions(
if (node?.type === 'input.form') { if (node?.type === 'input.form') {
options.push({ options.push({
ref: createRef(nodeId, []), ref: createRef(nodeId, []),
label: `Alle Formularfelder (${nodeLabel})`, label: `${translate('Alle Formularfelder')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -71,11 +74,11 @@ function buildLoopOptions(
if (node?.type === 'input.upload') { if (node?.type === 'input.upload') {
options.push({ options.push({
ref: createRef(nodeId, ['files']), ref: createRef(nodeId, ['files']),
label: `Alle hochgeladenen Dateien (${nodeLabel})`, label: `${translate('Alle hochgeladenen Dateien')} (${nodeLabel})`,
}); });
options.push({ options.push({
ref: createRef(nodeId, ['fileIds']), ref: createRef(nodeId, ['fileIds']),
label: `Alle Datei-IDs (${nodeLabel})`, label: `${translate('Alle Datei-IDs')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -83,7 +86,7 @@ function buildLoopOptions(
if (node?.type === 'flow.loop') { if (node?.type === 'flow.loop') {
options.push({ options.push({
ref: createRef(nodeId, ['items']), ref: createRef(nodeId, ['items']),
label: `Alle Elemente aus Schleife (${nodeLabel})`, label: `${translate('Alle Elemente aus Schleife')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -91,7 +94,7 @@ function buildLoopOptions(
if (node?.type === 'email.searchEmail') { if (node?.type === 'email.searchEmail') {
options.push({ options.push({
ref: createRef(nodeId, ['data', 'searchResults', 'results']), ref: createRef(nodeId, ['data', 'searchResults', 'results']),
label: `Alle gefundenen E-Mails (${nodeLabel})`, label: `${translate('Alle gefundenen E-Mails')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -99,7 +102,7 @@ function buildLoopOptions(
if (node?.type === 'email.checkEmail') { if (node?.type === 'email.checkEmail') {
options.push({ options.push({
ref: createRef(nodeId, ['data', 'emails', 'emails']), ref: createRef(nodeId, ['data', 'emails', 'emails']),
label: `Alle E-Mails (${nodeLabel})`, label: `${translate('Alle E-Mails')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -107,7 +110,7 @@ function buildLoopOptions(
if (node?.type === 'sharepoint.listFiles') { if (node?.type === 'sharepoint.listFiles') {
options.push({ options.push({
ref: createRef(nodeId, ['files']), ref: createRef(nodeId, ['files']),
label: `Alle Dateien (${nodeLabel})`, label: `${translate('Alle Dateien')} (${nodeLabel})`,
}); });
continue; continue;
} }
@ -153,11 +156,11 @@ interface LoopItemsSelectProps {
placeholder?: string; placeholder?: string;
} }
export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({ value,
value,
onChange, onChange,
placeholder = 'Über was soll iteriert werden?', placeholder,
}) => { }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null; if (!dataFlow) return null;
@ -165,7 +168,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
if (sourceIds.length === 0) { if (sourceIds.length === 0) {
return ( return (
<p className={styles.dynamicValueEmptyHint}> <p className={styles.dynamicValueEmptyHint}>
Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife. {t('Keine vorherigen Nodes verbunden. Verbinden Sie zuerst Nodes mit der Schleife.')}
</p> </p>
); );
} }
@ -174,7 +177,8 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
sourceIds, sourceIds,
dataFlow.nodes, dataFlow.nodes,
dataFlow.nodeOutputsPreview, dataFlow.nodeOutputsPreview,
dataFlow.getNodeLabel dataFlow.getNodeLabel,
t
); );
const ref = isRef(value) ? value : null; const ref = isRef(value) ? value : null;
@ -182,7 +186,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
return ( return (
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Datenquelle für Iteration</label> <label>{t('Datenquelle für Iteration')}</label>
<select <select
value={currentValue} value={currentValue}
onChange={(e) => { onChange={(e) => {
@ -196,7 +200,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
}} }}
className={styles.startsInput} className={styles.startsInput}
> >
<option value="">{placeholder}</option> <option value="">{placeholder ?? t('Über was soll iteriert werden?')}</option>
{options.map((o) => ( {options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}> <option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label} {o.label}
@ -204,7 +208,7 @@ export const LoopItemsSelect: React.FC<LoopItemsSelectProps> = ({
))} ))}
</select> </select>
<p className={styles.nodeConfigNameHint}> <p className={styles.nodeConfigNameHint}>
Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche. {t('Z.B. für jedes Formularfeld, jede Datei aus Upload, jede E-Mail aus Suche.')}
</p> </p>
</div> </div>
); );

View file

@ -6,6 +6,7 @@
import React from 'react'; import React from 'react';
import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef'; import { createRef, isRef, isValue, createValue, type DataRef } from './dataRef';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { useLanguage } from '../../../../providers/language/LanguageContext';
/** How to build path options for StatischKontextSelect / RefSourceSelect. */ /** How to build path options for StatischKontextSelect / RefSourceSelect. */
export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms'; export type PathPickMode = 'default' | 'clickup_task_id' | 'exclude_forms';
@ -131,6 +132,11 @@ export function refToOptionValue(ref: DataRef): string {
return JSON.stringify(ref); return JSON.stringify(ref);
} }
function _pathLabelForDisplay(pathLabel: string, translate: (key: string) => string): string {
if (pathLabel === 'Aufgaben-ID') return translate('Aufgaben-ID');
return pathLabel;
}
export function optionValueToRef(s: string): DataRef | null { export function optionValueToRef(s: string): DataRef | null {
try { try {
const o = JSON.parse(s) as unknown; const o = JSON.parse(s) as unknown;
@ -190,10 +196,11 @@ interface StatischKontextSelectProps {
export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
value, value,
onChange, onChange,
placeholder = '— Quelle wählen —', placeholder,
staticLabel = 'Statisch', staticLabel,
pathPickMode = 'default', pathPickMode = 'default',
}) => { }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null; if (!dataFlow) return null;
@ -213,7 +220,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId; const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode); const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) { for (const p of paths) {
const displayLabel = p.pathLabel ? `${nodeLabel}${p.pathLabel}` : nodeLabel; const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({ options.push({
ref: createRef(nodeId, p.path), ref: createRef(nodeId, p.path),
label: displayLabel, label: displayLabel,
@ -245,8 +253,8 @@ export const StatischKontextSelect: React.FC<StatischKontextSelectProps> = ({
if (ref) onChange(ref); if (ref) onChange(ref);
}} }}
> >
<option value="">{placeholder}</option> <option value="">{placeholder ?? t('— Quelle wählen —')}</option>
<option value={STATIC_SOURCE_VALUE}>{staticLabel}</option> <option value={STATIC_SOURCE_VALUE}>{staticLabel ?? t('Statisch')}</option>
{options.map((o) => ( {options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}> <option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label} {o.label}
@ -267,9 +275,10 @@ interface RefSourceSelectProps {
export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
value, value,
onChange, onChange,
placeholder = 'Datenquelle wählen…', placeholder,
pathPickMode = 'default', pathPickMode = 'default',
}) => { }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
if (!dataFlow) return null; if (!dataFlow) return null;
@ -289,7 +298,8 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId; const nodeLabel = node ? dataFlow.getNodeLabel(node) : nodeId;
const paths = pickPathsForNode(node, preview, pathPickMode); const paths = pickPathsForNode(node, preview, pathPickMode);
for (const p of paths) { for (const p of paths) {
const displayLabel = p.pathLabel ? `${nodeLabel}${p.pathLabel}` : nodeLabel; const pathLabelUi = _pathLabelForDisplay(p.pathLabel, t);
const displayLabel = pathLabelUi ? `${nodeLabel}${pathLabelUi}` : nodeLabel;
options.push({ options.push({
ref: createRef(nodeId, p.path), ref: createRef(nodeId, p.path),
label: displayLabel, label: displayLabel,
@ -312,7 +322,7 @@ export const RefSourceSelect: React.FC<RefSourceSelectProps> = ({
if (ref) onChange(ref); if (ref) onChange(ref);
}} }}
> >
<option value="">{placeholder}</option> <option value="">{placeholder ?? t('Datenquelle wählen…')}</option>
{options.map((o) => ( {options.map((o) => (
<option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}> <option key={refToOptionValue(o.ref)} value={refToOptionValue(o.ref)}>
{o.label} {o.label}
@ -343,13 +353,13 @@ function getFormFieldType(
if (!fieldName) return null; if (!fieldName) return null;
const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName); const field = raw.find((f: unknown) => f && typeof f === 'object' && (f as Record<string, unknown>).name === fieldName);
if (!field || typeof field !== 'object') return null; if (!field || typeof field !== 'object') return null;
const t = String((field as Record<string, unknown>).type ?? 'text').toLowerCase(); const rawFieldType = String((field as Record<string, unknown>).type ?? 'text').toLowerCase();
if (t === 'number') return 'number'; if (rawFieldType === 'number') return 'number';
if (t === 'email') return 'email'; if (rawFieldType === 'email') return 'email';
if (t === 'date' || t === 'datetime') return 'date'; if (rawFieldType === 'date' || rawFieldType === 'datetime') return 'date';
if (t === 'boolean' || t === 'checkbox') return 'boolean'; if (rawFieldType === 'boolean' || rawFieldType === 'checkbox') return 'boolean';
if (t === 'clickup_tasks') return 'string'; if (rawFieldType === 'clickup_tasks') return 'string';
if (t === 'clickup_status') return 'string'; if (rawFieldType === 'clickup_status') return 'string';
return 'string'; return 'string';
} }

View file

@ -16,10 +16,25 @@ export interface DataValue {
value: unknown; value: unknown;
} }
/** Union: either a reference or a static value */ /** System variable reference */
export type DynamicValue = DataRef | DataValue; export interface SystemVarRef {
type: 'system';
variable: string;
}
/** Union: reference, static value, or system variable */
export type DynamicValue = DataRef | DataValue | SystemVarRef;
/** Type guards */ /** Type guards */
export function isSystemVar(v: unknown): v is SystemVarRef {
return (
typeof v === 'object' &&
v !== null &&
(v as SystemVarRef).type === 'system' &&
typeof (v as SystemVarRef).variable === 'string'
);
}
export function isRef(v: unknown): v is DataRef { export function isRef(v: unknown): v is DataRef {
return ( return (
typeof v === 'object' && typeof v === 'object' &&
@ -39,7 +54,12 @@ export function isValue(v: unknown): v is DataValue {
} }
export function isDynamicValue(v: unknown): v is DynamicValue { export function isDynamicValue(v: unknown): v is DynamicValue {
return isRef(v) || isValue(v); return isRef(v) || isValue(v) || isSystemVar(v);
}
/** Create a system variable reference */
export function createSystemVar(variable: string): SystemVarRef {
return { type: 'system', variable };
} }
/** Create a reference object */ /** Create a reference object */

View file

@ -8,7 +8,7 @@ import type {
Automation2Graph, Automation2Graph,
Automation2GraphNode, Automation2GraphNode,
Automation2Connection, Automation2Connection,
} from '../../../../api/automation2Api'; } from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas'; import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
export function fromApiGraph( export function fromApiGraph(
@ -27,6 +27,7 @@ export function fromApiGraph(
const cases = (n.parameters?.cases as unknown[]) ?? []; const cases = (n.parameters?.cases as unknown[]) ?? [];
outputs = Math.max(1, cases.length); outputs = Math.max(1, cases.length);
} }
const nt = nodeTypes.find((t) => t.id === n.type);
return { return {
id: n.id, id: n.id,
type: n.type, type: n.type,
@ -37,6 +38,12 @@ export function fromApiGraph(
inputs: io.inputs, inputs: io.inputs,
outputs, outputs,
parameters: n.parameters ?? {}, parameters: n.parameters ?? {},
inputPorts: nt?.inputPorts
? 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 ?? '' }))
: undefined,
}; };
}); });
@ -71,6 +78,8 @@ export function toApiGraph(
title: n.title, title: n.title,
comment: n.comment, comment: n.comment,
parameters: n.parameters ?? {}, parameters: n.parameters ?? {},
inputPorts: n.inputPorts,
outputPorts: n.outputPorts,
})), })),
connections: connections.map((c) => { connections: connections.map((c) => {
const srcNode = nodeMap.get(c.sourceId); const srcNode = nodeMap.get(c.sourceId);

View file

@ -0,0 +1,92 @@
/**
* Automation2 Flow Editor - Schema-based output preview builders.
* Derives preview trees from portTypeCatalog + node outputPorts.
*/
import type { CanvasNode } from '../../editor/FlowCanvas';
import type { PortSchema, NodeType } from '../../../../api/workflowApi';
let _portTypeCatalog: Record<string, PortSchema> = {};
export function setPortTypeCatalog(catalog: Record<string, PortSchema>): void {
_portTypeCatalog = catalog;
}
function _defaultForType(typeStr: string): unknown {
if (typeStr.startsWith('List')) return [];
if (typeStr.startsWith('Dict')) return {};
if (typeStr === 'bool') return false;
if (typeStr === 'int') return 0;
if (typeStr === 'str') return '...';
if (typeStr === 'Any') return '...';
return null;
}
function _buildSchemaPreview(schemaName: string): Record<string, unknown> {
const schema = _portTypeCatalog[schemaName];
if (!schema || !schema.fields) return {};
const result: Record<string, unknown> = {};
for (const field of schema.fields) {
result[field.name] = _defaultForType(field.type);
}
result._success = true;
result._error = null;
return result;
}
function _buildDynamicFormPreview(node: CanvasNode): Record<string, unknown> {
const fields = (node.parameters?.fields ?? node.parameters?.formFields) as
| Array<{ name?: string; type?: string }>
| undefined;
if (!Array.isArray(fields)) return { payload: {} };
const payload: Record<string, unknown> = {};
for (const f of fields) {
if (f && typeof f === 'object' && f.name) {
payload[f.name] = '';
}
}
return { payload, _success: true, _error: null };
}
/** Build preview for a single node using its outputPorts schema */
export function buildNodeOutputPreview(
node: CanvasNode,
nodeTypeDef?: NodeType
): unknown {
const outputPorts = nodeTypeDef?.outputPorts;
if (!outputPorts) return {};
const port0 = outputPorts[0];
if (!port0) return {};
if (port0.dynamic && port0.deriveFrom) {
if (port0.schema === 'FormPayload') {
return _buildDynamicFormPreview(node);
}
}
if (port0.schema === 'Transit') {
return { _transit: true, _meta: {}, data: {} };
}
return _buildSchemaPreview(port0.schema);
}
/** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview(
nodes: CanvasNode[],
nodeTypes: NodeType[],
nodeOutputsFromRun?: Record<string, unknown>
): Record<string, unknown> {
const typeMap = new Map(nodeTypes.map((nt) => [nt.id, nt]));
const result: Record<string, unknown> = {};
for (const n of nodes) {
const fromRun = nodeOutputsFromRun?.[n.id];
if (fromRun !== undefined) {
result[n.id] = fromRun;
} else {
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
}
}
return result;
}

View file

@ -2,7 +2,7 @@
* Shared types for node config renderers * Shared types for node config renderers
*/ */
import type { ApiRequestFunction } from '../../../../api/automation2Api'; import type { ApiRequestFunction } from '../../../../api/workflowApi';
/** 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. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
export type FormField = { export type FormField = {

View file

@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */ /** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string; 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(',');
}

View file

@ -3,9 +3,11 @@
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
type FormField = { type FormField = {
name: string; name: string;
label: string; label: string;
@ -15,17 +17,17 @@ type FormField = {
const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const; const FORM_FIELD_TYPES = ['text', 'number', 'email', 'date', 'boolean', 'clickup_status'] as const;
function parseFields(params: Record<string, unknown>): FormField[] { function _parseFields(params: Record<string, unknown>, t: (key: string) => string): FormField[] {
const raw = params.formFields; const raw = params.formFields;
if (!Array.isArray(raw)) return [{ name: 'field1', label: 'Feld 1', type: 'text' }]; if (!Array.isArray(raw)) return [{ name: 'field1', label: t('Feld 1'), type: 'text' }];
return raw.map((f, i) => { return raw.map((f, i) => {
if (f && typeof f === 'object' && !Array.isArray(f)) { if (f && typeof f === 'object' && !Array.isArray(f)) {
const o = f as Record<string, unknown>; const o = f as Record<string, unknown>;
const t = String(o.type ?? 'text'); const fieldType = String(o.type ?? 'text');
const name = String(o.name ?? `field${i + 1}`); const name = String(o.name ?? `field${i + 1}`);
const label = String(o.label ?? `Feld ${i + 1}`); const label = String(o.label ?? `${t('Feld')} ${i + 1}`);
const type = ( const type = (
FORM_FIELD_TYPES.includes(t as (typeof FORM_FIELD_TYPES)[number]) ? t : 'text' FORM_FIELD_TYPES.includes(fieldType as (typeof FORM_FIELD_TYPES)[number]) ? fieldType : 'text'
) as FormField['type']; ) as FormField['type'];
if (type === 'clickup_status' && Array.isArray(o.statusOptions)) { if (type === 'clickup_status' && Array.isArray(o.statusOptions)) {
return { return {
@ -37,12 +39,13 @@ function parseFields(params: Record<string, unknown>): FormField[] {
} }
return { name, label, type }; return { name, label, type };
} }
return { name: `field${i + 1}`, label: `Feld ${i + 1}`, type: 'text' as const }; return { name: `field${i + 1}`, label: `${t('Feld')} ${i + 1}`, type: 'text' as const };
}); });
} }
export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const fields = useMemo(() => parseFields(params), [params]); const { t } = useLanguage();
const fields = useMemo(() => _parseFields(params, t), [params, t]);
const setFields = (next: FormField[]) => { const setFields = (next: FormField[]) => {
updateParam('formFields', next); updateParam('formFields', next);
@ -51,15 +54,16 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
return ( return (
<div className={styles.startNodeDoc}> <div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}> <p className={styles.startNodeDocIntro}>
<strong>Formular-Felder</strong> werden beim Start ausgefüllt und liegen unter{' '} <strong>{t('Formular-Felder')}</strong>{' '}
<code>payload.&lt;name&gt;</code> in der Start-Ausgabe. {t('werden beim Start ausgefüllt und liegen unter')}{' '}
<code>payload.&lt;name&gt;</code> {t('in der Start-Ausgabe.')}
</p> </p>
<div className={styles.formFieldsList}> <div className={styles.formFieldsList}>
{fields.map((f, idx) => ( {fields.map((f, idx) => (
<div key={idx} className={styles.formFieldRow}> <div key={idx} className={styles.formFieldRow}>
<input <input
className={styles.startsInput} className={styles.startsInput}
placeholder="Name (Payload-Key)" placeholder={t('Name (Payload-Key)')}
value={f.name} value={f.name}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
@ -69,7 +73,7 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
/> />
<input <input
className={styles.startsInput} className={styles.startsInput}
placeholder="Beschriftung" placeholder={t('Beschriftung')}
value={f.label} value={f.label}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
@ -82,21 +86,21 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
value={f.type} value={f.type}
onChange={(e) => { onChange={(e) => {
const next = [...fields]; const next = [...fields];
const t = e.target.value as FormField['type']; const fieldType = e.target.value as FormField['type'];
if (t === 'clickup_status') { if (fieldType === 'clickup_status') {
next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions }; next[idx] = { name: f.name, label: f.label, type: 'clickup_status', statusOptions: f.statusOptions };
} else { } else {
next[idx] = { name: f.name, label: f.label, type: t }; next[idx] = { name: f.name, label: f.label, type: fieldType };
} }
setFields(next); setFields(next);
}} }}
> >
<option value="text">Text</option> <option value="text">{t('Text')}</option>
<option value="number">Zahl</option> <option value="number">{t('Zahl')}</option>
<option value="email">E-Mail</option> <option value="email">{t('E-Mail')}</option>
<option value="date">Datum</option> <option value="date">{t('Datum')}</option>
<option value="boolean">Ja/Nein</option> <option value="boolean">{t('Ja/Nein')}</option>
<option value="clickup_status">ClickUp-Status (Liste)</option> <option value="clickup_status">{t('ClickUp-Status Liste')}</option>
</select> </select>
<button <button
type="button" type="button"
@ -111,10 +115,10 @@ export const FormStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
type="button" type="button"
className={styles.startsAddBtn} className={styles.startsAddBtn}
onClick={() => onClick={() =>
setFields([...fields, { name: `field${fields.length + 1}`, label: 'Neues Feld', type: 'text' }]) setFields([...fields, { name: `field${fields.length + 1}`, label: t('Neues Feld'), type: 'text' }])
} }
> >
+ Feld {t('+ Feld')}
</button> </button>
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion'; import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { import {
type ScheduleSpec, type ScheduleSpec,
type ScheduleMode, type ScheduleMode,
@ -16,36 +16,35 @@ import {
} from '../runtime/scheduleCron'; } from '../runtime/scheduleCron';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
const MODE_OPTIONS: { value: ScheduleMode; title: string; subtitle: string }[] = [ import { useLanguage } from '../../../../providers/language/LanguageContext';
{ value: 'daily', title: 'Täglich', subtitle: 'Jeden Tag zur gleichen Zeit' },
{ value: 'weekdays', title: 'Werktage', subtitle: 'Montag bis Freitag' },
{ value: 'weekly', title: 'Bestimmte Tage', subtitle: 'Wochentage auswählen' },
{ value: 'calendar', title: 'Ein anderer Zeitraum', subtitle: 'Monatlich oder jährlich wiederkehrend' },
{ value: 'interval', title: 'Intervall', subtitle: 'In regelmäßigen Abständen' },
];
const MONTH_NAMES_DE = [ function _getModeOptions(t: (key: string) => string): { value: ScheduleMode; title: string; subtitle: string }[] {
'Januar', return [
'Februar', { value: 'daily', title: t('Täglich'), subtitle: t('Jeden Tag zur gleichen Zeit') },
'März', { value: 'weekdays', title: t('Werktage'), subtitle: t('Montag bis Freitag') },
'April', { value: 'weekly', title: t('Bestimmte Tage'), subtitle: t('Wochentage auswählen') },
'Mai', { value: 'calendar', title: t('Ein anderer Zeitraum'), subtitle: t('Monatlich oder jährlich') },
'Juni', { value: 'interval', title: t('Intervall'), subtitle: t('In regelmäßigen Abständen') },
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
]; ];
}
const INTERVAL_UNITS: { value: IntervalUnit; label: string; title: string }[] = [ function _monthNames(t: (k: string) => string): string[] {
{ value: 'seconds', label: 'sek', title: 'Sekunden' }, return [
{ value: 'minutes', label: 'min', title: 'Minuten' }, t('Januar'), t('Februar'), t('März'), t('April'),
{ value: 'hours', label: 'h', title: 'Stunden' }, t('Mai'), t('Juni'), t('Juli'), t('August'),
{ value: 'days', label: 'd', title: 'Tage' }, t('September'), t('Oktober'), t('November'), t('Dezember'),
{ value: 'years', label: 'a', title: 'Jahre' },
]; ];
}
function _getIntervalUnits(t: (key: string) => string): { value: IntervalUnit; label: string; title: string }[] {
return [
{ value: 'seconds', label: t('Sekunde'), title: t('Sekunden') },
{ value: 'minutes', label: t('Minute'), title: t('Minuten') },
{ value: 'hours', label: t('Stunde'), title: t('Stunden') },
{ value: 'days', label: t('Tag'), title: t('Tage') },
{ value: 'years', label: t('Jahr'), title: t('Jahre') },
];
}
function timeString(hour: number, minute: number): string { function timeString(hour: number, minute: number): string {
return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`; return `${String(Math.max(0, Math.min(23, hour))).padStart(2, '0')}:${String(Math.max(0, Math.min(59, minute))).padStart(2, '0')}`;
@ -77,6 +76,9 @@ function clampInterval(value: number, unit: IntervalUnit): number {
const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const; const EASE_SMOOTH = [0.33, 1, 0.68, 1] as const;
export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const modeOptions = _getModeOptions(t);
const intervalUnits = _getIntervalUnits(t);
const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params)); const [spec, setSpec] = useState<ScheduleSpec>(() => scheduleSpecFromParams(params));
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const specModeRef = useRef(spec.mode); const specModeRef = useRef(spec.mode);
@ -128,7 +130,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
const onModeCardPointerEvent = ( const onModeCardPointerEvent = (
phase: 'pointerdown' | 'click', phase: 'pointerdown' | 'click',
e: React.PointerEvent | React.MouseEvent, e: React.PointerEvent | React.MouseEvent,
o: (typeof MODE_OPTIONS)[number] o: { value: ScheduleMode; title: string; subtitle: string }
) => { ) => {
const el = e.target as HTMLElement; const el = e.target as HTMLElement;
const cur = e.currentTarget as HTMLElement; const cur = e.currentTarget as HTMLElement;
@ -200,13 +202,14 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
return ( return (
<div className={styles.schedulePanel}> <div className={styles.schedulePanel}>
<p className={styles.startNodeDocIntro}> <p className={styles.startNodeDocIntro}>
Legen Sie fest, <strong>wann</strong> dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird {t(
unten automatisch erzeugt. 'Legen Sie fest, wann dieser Workflow automatisch laufen soll. Der technische Cron-Ausdruck wird unten automatisch erzeugt.'
)}
</p> </p>
<LayoutGroup> <LayoutGroup>
<div className={styles.scheduleModeStack}> <div className={styles.scheduleModeStack}>
{MODE_OPTIONS.map((o) => ( {modeOptions.map((o) => (
<motion.div <motion.div
key={o.value} key={o.value}
data-schedule-mode={o.value} data-schedule-mode={o.value}
@ -246,7 +249,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
<div className={styles.scheduleModeConfig}> <div className={styles.scheduleModeConfig}>
{o.value === 'daily' && ( {o.value === 'daily' && (
<label className={styles.scheduleFieldRow}> <label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span> <span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input <input
type="time" type="time"
step={60} step={60}
@ -259,7 +262,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'weekdays' && ( {o.value === 'weekdays' && (
<label className={styles.scheduleFieldRow}> <label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span> <span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input <input
type="time" type="time"
step={60} step={60}
@ -273,9 +276,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'weekly' && ( {o.value === 'weekly' && (
<> <>
<div className={styles.scheduleFieldCol}> <div className={styles.scheduleFieldCol}>
<span className={styles.scheduleFieldLabel}>Wochentage</span> <span className={styles.scheduleFieldLabel}>{t('Wochentage')}</span>
<div className={styles.scheduleWeekdayToggles}> <div className={styles.scheduleWeekdayToggles}>
{WEEKDAYS_MO_SO.map(({ cronDow, label }) => ( {WEEKDAYS_MO_SO.map(({ cronDow }) => (
<button <button
key={cronDow} key={cronDow}
type="button" type="button"
@ -284,13 +287,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
} }
onClick={() => toggleWeekday(cronDow)} onClick={() => toggleWeekday(cronDow)}
> >
{label} {cronDow === 1 ? t('Mo') : cronDow === 2 ? t('Di') : cronDow === 3 ? t('Mi') : cronDow === 4 ? t('Do') : cronDow === 5 ? t('Fr') : cronDow === 6 ? t('Sa') : t('So')}
</button> </button>
))} ))}
</div> </div>
</div> </div>
<label className={styles.scheduleFieldRow}> <label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span> <span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input <input
type="time" type="time"
step={60} step={60}
@ -314,7 +317,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
} }
onClick={() => setCalendarPeriod('monthly')} onClick={() => setCalendarPeriod('monthly')}
> >
Monatlich {t('Monatlich')}
</button> </button>
<button <button
type="button" type="button"
@ -325,13 +328,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
} }
onClick={() => setCalendarPeriod('yearly')} onClick={() => setCalendarPeriod('yearly')}
> >
Jährlich {t('Jährlich')}
</button> </button>
</div> </div>
{spec.calendarPeriod === 'monthly' && ( {spec.calendarPeriod === 'monthly' && (
<label className={styles.scheduleFieldRow}> <label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Monatstag</span> <span className={styles.scheduleFieldLabel}>{t('Monatstag')}</span>
<select <select
className={styles.scheduleSelect} className={styles.scheduleSelect}
value={spec.monthDay} value={spec.monthDay}
@ -349,13 +352,13 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{spec.calendarPeriod === 'yearly' && ( {spec.calendarPeriod === 'yearly' && (
<div className={styles.scheduleYearlyRow}> <div className={styles.scheduleYearlyRow}>
<label className={styles.scheduleFieldRowGrow}> <label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Monat</span> <span className={styles.scheduleFieldLabel}>{t('Monat')}</span>
<select <select
className={styles.scheduleSelect} className={styles.scheduleSelect}
value={spec.monthIndex} value={spec.monthIndex}
onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })} onChange={(e) => push({ ...spec, monthIndex: Number(e.target.value) })}
> >
{MONTH_NAMES_DE.map((name, i) => ( {_monthNames(t).map((name, i) => (
<option key={i + 1} value={i + 1}> <option key={i + 1} value={i + 1}>
{name} {name}
</option> </option>
@ -363,7 +366,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
</select> </select>
</label> </label>
<label className={styles.scheduleFieldRowGrow}> <label className={styles.scheduleFieldRowGrow}>
<span className={styles.scheduleFieldLabel}>Tag</span> <span className={styles.scheduleFieldLabel}>{t('Tag')}</span>
<select <select
className={styles.scheduleSelect} className={styles.scheduleSelect}
value={spec.monthDay} value={spec.monthDay}
@ -380,7 +383,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
)} )}
<label className={styles.scheduleFieldRow}> <label className={styles.scheduleFieldRow}>
<span className={styles.scheduleFieldLabel}>Uhrzeit</span> <span className={styles.scheduleFieldLabel}>{t('Uhrzeit')}</span>
<input <input
type="time" type="time"
step={60} step={60}
@ -394,7 +397,7 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
{o.value === 'interval' && ( {o.value === 'interval' && (
<div className={styles.scheduleIntervalRow}> <div className={styles.scheduleIntervalRow}>
<span className={styles.scheduleFieldLabel}>Alle</span> <span className={styles.scheduleFieldLabel}>{t('Alle')}</span>
<input <input
type="number" type="number"
min={1} min={1}
@ -411,9 +414,9 @@ export const ScheduleStartNodeConfig: React.FC<NodeConfigRendererProps> = ({ par
className={styles.scheduleUnitSelect} className={styles.scheduleUnitSelect}
value={spec.intervalUnit} value={spec.intervalUnit}
onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)} onChange={(e) => setIntervalUnit(e.target.value as IntervalUnit)}
title={INTERVAL_UNITS.find((u) => u.value === spec.intervalUnit)?.title} title={intervalUnits.find((u) => u.value === spec.intervalUnit)?.title}
> >
{INTERVAL_UNITS.map((u) => ( {intervalUnits.map((u) => (
<option key={u.value} value={u.value} title={u.title}> <option key={u.value} value={u.value} title={u.title}>
{u.label} {u.label}
</option> </option>

View file

@ -4,8 +4,9 @@
*/ */
import React from 'react'; import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
const SCHEMA_EXAMPLE = `{ const SCHEMA_EXAMPLE = `{
"trigger": { "trigger": {
@ -22,17 +23,20 @@ const SCHEMA_EXAMPLE = `{
}`; }`;
export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => { export const StartNodeConfig: React.FC<NodeConfigRendererProps> = () => {
const { t } = useLanguage();
return ( return (
<div className={styles.startNodeDoc}> <div className={styles.startNodeDoc}>
<p className={styles.startNodeDocIntro}> <p className={styles.startNodeDocIntro}>
Die <strong>Start</strong>-Node liefert beim Ausführen immer dieselbe Struktur. Den <strong>Einstiegstyp</strong>{' '} {t(
(manuell, Formular, Zeitplan, ) wählen Sie über das <strong>Zahnrad</strong> oben in der 'Die Start-Node liefert beim Ausführen immer dieselbe Struktur. Den Einstiegstyp (manuell, Formular, Zeitplan, …) wählen Sie über das Zahnrad oben in der Workflow-Konfiguration.'
Workflow-Konfiguration. )}
</p>
<p className={styles.startNodeDocSub}>
{t('Nachgelagerte Nodes können z. B. auf')}{' '}
<code>payload</code> {t('und')} <code>trigger.type</code> {t('zugreifen.')}
</p> </p>
<p className={styles.startNodeDocSub}>Nachgelagerte Nodes können z.B. auf <code>payload</code> und{' '}
<code>trigger.type</code> zugreifen.</p>
<div className={styles.startNodeSchema}> <div className={styles.startNodeSchema}>
<div className={styles.startNodeSchemaTitle}>Ausgabe-Schema</div> <div className={styles.startNodeSchemaTitle}>{t('Ausgabe-Schema')}</div>
<pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre> <pre className={styles.startNodePre}>{SCHEMA_EXAMPLE}</pre>
</div> </div>
</div> </div>

View file

@ -4,7 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import type { NodeConfigRendererProps } from '../configs/types'; import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect'; import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext'; import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, createValue } from '../shared/dataRef'; import { isRef, createValue } from '../shared/dataRef';
@ -12,6 +12,8 @@ import { getMimeTypeOptionsFromUploadParams } from '../runtime/fileTypeMimeMappi
import { operatorsForType } from '../shared/conditionOperators'; import { operatorsForType } from '../shared/conditionOperators';
import styles from '../../editor/Automation2FlowEditor.module.css'; import styles from '../../editor/Automation2FlowEditor.module.css';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export interface SwitchCase { export interface SwitchCase {
operator: string; operator: string;
value?: string | number | boolean; value?: string | number | boolean;
@ -31,6 +33,7 @@ function normalizeCase(c: unknown): SwitchCase {
} }
export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => { export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow(); const dataFlow = useAutomation2DataFlow();
const valueParam = params.value; const valueParam = params.value;
@ -111,7 +114,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
onChange={(e) => handleCaseValueChange(index, e.target.value)} onChange={(e) => handleCaseValueChange(index, e.target.value)}
className={styles.startsInput} className={styles.startsInput}
> >
<option value=""> MIME-Type wählen </option> <option value="">{t('MIME-Typ wählen')}</option>
{mimeTypeOptions.map((o) => ( {mimeTypeOptions.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
{o.label} ({o.value}) {o.label} ({o.value})
@ -151,9 +154,9 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
}} }}
className={styles.startsInput} className={styles.startsInput}
> >
<option value=""> wählen </option> <option value="">{t('Wählen')}</option>
<option value="true">Ja / wahr</option> <option value="true">{t('Ja (true)')}</option>
<option value="false">Nein / falsch</option> <option value="false">{t('Nein (false)')}</option>
</select> </select>
); );
} }
@ -163,7 +166,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
className={styles.startsInput} className={styles.startsInput}
value={valStr} value={valStr}
onChange={(e) => handleCaseValueChange(index, e.target.value)} onChange={(e) => handleCaseValueChange(index, e.target.value)}
placeholder={isMimeTypeRef ? 'z.B. application/pdf' : `Wert`} placeholder={isMimeTypeRef ? t('z.B. application/pdf') : t('Wert')}
/> />
); );
}; };
@ -182,28 +185,28 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
return ( return (
<div className={styles.ifElseConditionEditor}> <div className={styles.ifElseConditionEditor}>
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Datenquelle</label> <label>{t('Datenquelle')}</label>
<RefSourceSelect <RefSourceSelect
value={ref} value={ref}
onChange={handleRefChange} onChange={handleRefChange}
placeholder="Feld zum Vergleichen wählen…" placeholder={t('Feld zum Vergleich wählen')}
/> />
</div> </div>
{!ref && ( {!ref && (
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Fester Wert (falls keine Referenz)</label> <label>{t('Fester Wert (ohne Referenz)')}</label>
<input <input
type="text" type="text"
value={String(staticValue ?? '')} value={String(staticValue ?? '')}
onChange={(e) => handleStaticValueChange(e.target.value)} onChange={(e) => handleStaticValueChange(e.target.value)}
placeholder="z.B. CH oder 42" placeholder={t('z. B. CH oder 42')}
/> />
</div> </div>
)} )}
<div className={styles.ifElseConditionRow}> <div className={styles.ifElseConditionRow}>
<label>Fälle (Reihenfolge = Ausgang)</label> <label>{t('Fälle / Reihenfolge / Ausgabe')}</label>
<div className={styles.formFieldsList}> <div className={styles.formFieldsList}>
{cases.map((c, i) => { {cases.map((c, i) => {
const opDef = operators.find((o) => o.value === c.operator) ?? operators[0]; const opDef = operators.find((o) => o.value === c.operator) ?? operators[0];
@ -238,7 +241,7 @@ export const SwitchNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, up
); );
})} })}
<button type="button" className={styles.startsAddBtn} onClick={addCase}> <button type="button" className={styles.startsAddBtn} onClick={addCase}>
+ Fall {t('+ Fall')}
</button> </button>
</div> </div>
</div> </div>

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