([]);
- 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 (
- <>
-
- Account
- updateParam('connectionId', e.target.value)}
- disabled={loading}
- >
- {loading ? t('emailNodeConfig.loading') : t('emailNodeConfig.selectConnection')}
- {connections.map((c) => (
-
- {c.externalEmail ?? c.externalUsername ?? c.id}
-
- ))}
-
-
- {!isDraft && (
-
- Folder
- updateParam('folder', e.target.value)}
- disabled={foldersLoading || !connectionId}
- >
-
- {foldersLoading ? 'Loading folders...' : !connectionId ? t('emailNodeConfig.selectAccountFirst') : t('emailNodeConfig.selectFolder')}
-
- {isSearch && All }
- {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 (
-
- {f.name}
-
- );
- })
- : !isSearch && (
- <>
- Inbox
- Drafts
- {t('emailNodeConfig.sentItems')}
- {t('emailNodeConfig.deletedItems')}
- {t('emailNodeConfig.junkEmail')}
- >
- )}
- {folderValue &&
- !folders.some(
- (f) =>
- ((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
- ) &&
- folderValue !== 'All' && (
- {folderValue}
- )}
-
-
- )}
- {isSearch && (
- <>
-
- {t('emailNodeConfig.searchQueryOptional')}
- updateParam('query', e.target.value)}
- placeholder={t('emailNodeConfig.generalSearchTermSubjectBody')}
- />
-
-
- {t('emailNodeConfig.fromAddressOptional')}
- updateParam('fromAddress', e.target.value)}
- placeholder={t('emailNodeConfig.egSenderexamplecom')}
- />
-
-
- {t('emailNodeConfig.toAddressOptional')}
- updateParam('toAddress', e.target.value)}
- placeholder={t('emailNodeConfig.egRecipientexamplecom')}
- />
-
-
- {t('emailNodeConfig.subjectContainsOptional')}
- updateParam('subjectContains', e.target.value)}
- placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
- />
-
-
- {t('emailNodeConfig.bodycontentContainsOptional')}
- updateParam('bodyContains', e.target.value)}
- placeholder={t('emailNodeConfig.wordOrPhraseInEmail')}
- />
-
-
- updateParam('hasAttachment', e.target.checked)}
- />
- {t('emailNodeConfig.onlyEmailsWithAttachment')}
-
-
- Limit
- updateParam('limit', parseInt(e.target.value, 10) || 100)}
- />
-
- >
- )}
- {nodeType === 'email.checkEmail' && (
- <>
-
- {t('emailNodeConfig.fromAddressOptional')}
- updateParam('fromAddress', e.target.value)}
- placeholder={t('emailNodeConfig.egSenderexamplecom')}
- />
-
-
- {t('emailNodeConfig.subjectContainsOptional')}
- updateParam('subjectContains', e.target.value)}
- placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
- />
-
-
- updateParam('hasAttachment', e.target.checked)}
- />
- {t('emailNodeConfig.onlyEmailsWithAttachment')}
-
-
- Limit
- updateParam('limit', parseInt(e.target.value, 10) || 100)}
- />
-
- >
- )}
- {isDraft && (
- <>
-
- Subject
- updateParam('subject', e.target.value)}
- placeholder={t('emailNodeConfig.emailSubjectOrLeaveEmpty')}
- />
-
-
- Body
-
-
- {t('emailNodeConfig.toOptional')}
- updateParam('to', e.target.value)}
- placeholder={t('emailNodeConfig.recipientsOrFromAiWhen')}
- />
-
- >
- )}
- >
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx
deleted file mode 100644
index bc8eb83..0000000
--- a/src/components/FlowEditor/nodes/configs/FileCreateNodeConfig.tsx
+++ /dev/null
@@ -1,124 +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';
-
-import { useLanguage } from '../../../../providers/language/LanguageContext';
-
-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 = ({ params, updateParam }) => {
- const { t } = useLanguage();
- 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 (
- <>
-
-
{t('fileCreateNodeConfig.inhalteWelcheKontexteNacheinanderIn')}
- {contentSources.map((ref, i) => (
-
- setItem(i, r)}
- placeholder={t('fileCreateNodeConfig.quelleWaehlen')}
- />
- removeItem(i)}
- title={t('fileCreateNodeConfig.entfernen')}
- aria-label={t('fileCreateNodeConfig.inhaltEntfernen')}
- >
- ×
-
-
- ))}
-
- + Inhalt hinzufügen
-
- {contentSources.length === 0 && (
-
- Leer = Kontext vom verbundenen Node. Fügen Sie Inhalte hinzu, um mehrere Quellen zu kombinieren.
-
- )}
-
-
- Ausgabeformat
- updateParam('outputFormat', e.target.value)}
- >
- {OUTPUT_FORMATS.map((opt) => (
-
- {opt}
-
- ))}
-
-
-
- Titel
- updateParam('title', e.target.value)}
- placeholder="Dokumenttitel"
- />
-
-
- {t('fileCreateNodeConfig.vorlageStil')}
- updateParam('templateName', e.target.value)}
- >
- {TEMPLATE_OPTIONS.map((opt) => (
-
- {opt}
-
- ))}
-
-
-
- {t('fileCreateNodeConfig.sprache')}
- updateParam('language', e.target.value)}
- >
- {LANGUAGES.map((opt) => (
-
- {opt}
-
- ))}
-
-
- >
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/ReviewNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/ReviewNodeConfig.tsx
deleted file mode 100644
index 20d0651..0000000
--- a/src/components/FlowEditor/nodes/configs/ReviewNodeConfig.tsx
+++ /dev/null
@@ -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 = ({ params, updateParam }) => (
-
-);
diff --git a/src/components/FlowEditor/nodes/configs/SelectionNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/SelectionNodeConfig.tsx
deleted file mode 100644
index adbbabb..0000000
--- a/src/components/FlowEditor/nodes/configs/SelectionNodeConfig.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-/**
- * Selection node config
- */
-
-import React from 'react';
-import type { NodeConfigRendererProps } from './types';
-
-export const SelectionNodeConfig: React.FC = ({ params, updateParam }) => {
- const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
- return (
-
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx
deleted file mode 100644
index 515484e..0000000
--- a/src/components/FlowEditor/nodes/configs/SharePointNodeConfig.tsx
+++ /dev/null
@@ -1,342 +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/workflowApi';
-import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
-
-import { useLanguage } from '../../../../providers/language/LanguageContext';
-
-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 = ({ params,
- updateParam,
- instanceId,
- request,
- nodeType = 'sharepoint.findFile',
-}) => {
- const { t } = useLanguage();
- const [connections, setConnections] = useState([]);
- 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 => {
- 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 (
- <>
-
- Connection
- updateParam('connectionId', e.target.value)}
- disabled={connectionsLoading}
- >
- {connectionsLoading ? t('sharePointNodeConfig.loading') : t('sharePointNodeConfig.selectConnection')}
- {connections.map((c) => (
-
- {c.externalUsername ?? c.id}
-
- ))}
-
-
-
- {needsSearch && (
-
- {t('sharePointNodeConfig.searchQueryPath')}
- updateParam('searchQuery', e.target.value)}
- placeholder="/sites/SiteName/Shared Documents or search term"
- />
-
- )}
-
- {showPathFieldsForList && (
-
- {t('sharePointNodeConfig.folderPath')}
- updateParam('path', e.target.value)}
- placeholder="/ or /sites/SiteName/Shared Documents/Folder"
- />
-
- )}
-
- {showPathFieldsForFileUploadDownload && (
-
-
- {nodeType === 'sharepoint.uploadFile'
- ? 'Target folder path'
- : nodeType === 'sharepoint.downloadFile'
- ? t('sharePointNodeConfig.filePath')
- : t('sharePointNodeConfig.path')}
-
- updateParam('path', e.target.value)}
- placeholder={
- nodeType === 'sharepoint.downloadFile'
- ? '/sites/SiteName/Shared Documents/file.pdf'
- : nodeType === 'sharepoint.uploadFile'
- ? '/sites/.../Shared Documents/TargetFolder/'
- : 'File path'
- }
- />
-
- )}
-
- {needsSiteId && (
-
- {t('sharePointNodeConfig.siteId')}
- updateParam('siteId', e.target.value)}
- placeholder={t('sharePointNodeConfig.sharepointSiteId')}
- />
-
- )}
-
- {nodeType === 'sharepoint.copyFile' && (
- <>
-
- {t('sharePointNodeConfig.sourceFile')}
- updateParam('sourcePath', e.target.value)}
- placeholder="/sites/.../folder/file.pdf"
- />
-
-
- {t('sharePointNodeConfig.destinationFolder')}
- updateParam('destPath', e.target.value)}
- placeholder="/sites/.../target-folder/"
- />
-
- {connectionId && (
- <>
- setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
- style={browseDetailsStyle}
- >
-
- 📂
- Quelldatei durchsuchen
-
-
-
-
-
- setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
- style={{ ...browseDetailsStyle, marginTop: 8 }}
- >
-
- 📂
- Zielordner durchsuchen
-
-
- {}}
- onSelectFolder={selectDestPath}
- selectedPath={(params.destPath as string) || null}
- />
-
-
- >
- )}
- >
- )}
-
- {showStandardPathBrowse && (
- setBrowseExpanded((e.target as HTMLDetailsElement).open)}
- style={browseDetailsStyle}
- >
-
- 📂
- {browsePanelTitle(nodeType)}
-
-
- {isFolderPickerNode(nodeType) && (
- {}}
- onSelectFolder={selectPath}
- selectedPath={path || null}
- />
- )}
- {(nodeType === 'sharepoint.readFile' || nodeType === 'sharepoint.downloadFile') && (
-
- )}
-
-
- )}
-
- {showFindFileBrowse && (
- setFindFileBrowseExpanded((e.target as HTMLDetailsElement).open)}
- style={browseDetailsStyle}
- >
-
- 📂
- {browsePanelTitle('sharepoint.findFile')}
-
-
-
-
-
- )}
- >
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx
deleted file mode 100644
index b660ce8..0000000
--- a/src/components/FlowEditor/nodes/configs/TrusteeNodeConfig.tsx
+++ /dev/null
@@ -1,94 +0,0 @@
-/**
- * Trustee node config — featureInstanceId, optional SharePoint connection + folder, prompt.
- * Covers: trustee.extractFromFiles, trustee.processDocuments, trustee.syncToAccounting.
- */
-
-import React, { useEffect, useState } from 'react';
-import type { NodeConfigRendererProps } from './types';
-import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
-
-import { useLanguage } from '../../../../providers/language/LanguageContext';
-
-export const TrusteeNodeConfig: React.FC = ({ params,
- updateParam,
- instanceId,
- request,
- nodeType = 'trustee.extractFromFiles',
-}) => {
- const { t } = useLanguage();
- const [connections, setConnections] = useState([]);
- const [loading, setLoading] = useState(false);
-
- const isExtract = nodeType === 'trustee.extractFromFiles';
-
- useEffect(() => {
- if (isExtract && instanceId && request) {
- setLoading(true);
- fetchConnections(request, instanceId)
- .then(setConnections)
- .catch(() => setConnections([]))
- .finally(() => setLoading(false));
- }
- }, [isExtract, instanceId, request]);
-
- return (
- <>
-
- {t('trusteeNodeConfig.trusteeInstanceId')}
- updateParam('featureInstanceId', e.target.value)}
- placeholder={t('trusteeNodeConfig.trusteeFeatureinstanzid')}
- />
-
-
- {isExtract && (
- <>
-
- {t('trusteeNodeConfig.sharepointConnectionOptional')}
- updateParam('connectionId', e.target.value)}
- disabled={loading}
- >
- {loading ? t('trusteeNodeConfig.laden') : t('trusteeNodeConfig.keineDateienAusVorherigemSchritt')}
- {connections.map((c) => (
-
- {c.externalUsername ?? c.id}
-
- ))}
-
-
-
- {t('trusteeNodeConfig.sharepointOrdnerpfadOptional')}
- updateParam('sharepointFolder', e.target.value)}
- placeholder="/sites/MySite/Documents/Expenses"
- />
-
-
- {t('trusteeNodeConfig.aiPromptOptional')}
-
- >
- )}
-
- {!isExtract && (
-
- {t('trusteeNodeConfig.documentListReferenz')}
- updateParam('documentList', e.target.value)}
- placeholder={t('trusteeNodeConfig.referenzAufVorherigenSchrittAutomatisch')}
- />
-
- )}
- >
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx b/src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx
deleted file mode 100644
index c5a4a0f..0000000
--- a/src/components/FlowEditor/nodes/configs/UploadNodeConfig.tsx
+++ /dev/null
@@ -1,83 +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';
-
-import { useLanguage } from '../../../../providers/language/LanguageContext';
-
-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 {
- const types = parseAllowedTypes(config);
- return buildAcceptString(types);
-}
-
-const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
-
-export const UploadNodeConfig: React.FC = ({ params, updateParam }) => {
- const { t } = useLanguage();
- 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 (
-
-
-
{t('uploadNodeConfig.erlaubteDateitypen')}
-
- Mehrfachauswahl möglich. Keine Auswahl = alle Typen erlaubt.
-
-
- {FILE_TYPE_CHIP_OPTIONS.map((opt) => (
-
- toggleType(opt.value)}
- />
- {opt.label}
-
- ))}
-
-
-
- {t('uploadNodeConfig.maxGroesseMb')}
- updateParam('maxSize', parseFloat(e.target.value) || 10)}
- />
-
-
-
- updateParam('multiple', e.target.checked)}
- />
- Mehrere Dateien erlauben
-
-
-
- );
-};
diff --git a/src/components/FlowEditor/nodes/configs/index.ts b/src/components/FlowEditor/nodes/configs/index.ts
deleted file mode 100644
index 10fe8a4..0000000
--- a/src/components/FlowEditor/nodes/configs/index.ts
+++ /dev/null
@@ -1,69 +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';
-import { TrusteeNodeConfig } from './TrusteeNodeConfig';
-
-export type NodeConfigComponent = ComponentType;
-
-export const NODE_CONFIG_REGISTRY: Record = {
- '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,
- 'trustee.extractFromFiles': TrusteeNodeConfig,
- 'trustee.processDocuments': TrusteeNodeConfig,
- 'trustee.syncToAccounting': TrusteeNodeConfig,
-};
diff --git a/src/components/FlowEditor/nodes/configs/types.ts b/src/components/FlowEditor/nodes/configs/types.ts
deleted file mode 100644
index ca2074c..0000000
--- a/src/components/FlowEditor/nodes/configs/types.ts
+++ /dev/null
@@ -1 +0,0 @@
-export type { NodeConfigRendererProps, FormField } from '../shared/types';
diff --git a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
index ebad145..eb071e9 100644
--- a/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/form/FormNodeConfig.tsx
@@ -4,7 +4,7 @@
import React, { useEffect, useState } from 'react';
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/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
diff --git a/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx b/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx
index a86b53b..46620cc 100644
--- a/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/ifElse/IfElseNodeConfig.tsx
@@ -4,7 +4,7 @@
*/
import React from 'react';
-import type { NodeConfigRendererProps } from '../configs/types';
+import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef } from '../shared/dataRef';
diff --git a/src/components/FlowEditor/nodes/loop/LoopNodeConfig.tsx b/src/components/FlowEditor/nodes/loop/LoopNodeConfig.tsx
index 3b69109..c801075 100644
--- a/src/components/FlowEditor/nodes/loop/LoopNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/loop/LoopNodeConfig.tsx
@@ -4,7 +4,7 @@
*/
import React from 'react';
-import type { NodeConfigRendererProps } from '../configs/types';
+import type { NodeConfigRendererProps } from '../shared/types';
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
import { createValue, isRef, isValue } from '../shared/dataRef';
import styles from '../../editor/Automation2FlowEditor.module.css';
diff --git a/src/components/FlowEditor/nodes/shared/graphUtils.ts b/src/components/FlowEditor/nodes/shared/graphUtils.ts
index 21655e4..4880bdb 100644
--- a/src/components/FlowEditor/nodes/shared/graphUtils.ts
+++ b/src/components/FlowEditor/nodes/shared/graphUtils.ts
@@ -27,6 +27,7 @@ export function fromApiGraph(
const cases = (n.parameters?.cases as unknown[]) ?? [];
outputs = Math.max(1, cases.length);
}
+ const nt = nodeTypes.find((t) => t.id === n.type);
return {
id: n.id,
type: n.type,
@@ -37,6 +38,8 @@ export function fromApiGraph(
inputs: io.inputs,
outputs,
parameters: n.parameters ?? {},
+ inputPorts: nt?.inputPorts,
+ outputPorts: nt?.outputPorts,
};
});
@@ -71,6 +74,8 @@ export function toApiGraph(
title: n.title,
comment: n.comment,
parameters: n.parameters ?? {},
+ inputPorts: n.inputPorts,
+ outputPorts: n.outputPorts,
})),
connections: connections.map((c) => {
const srcNode = nodeMap.get(c.sourceId);
diff --git a/src/components/FlowEditor/nodes/shared/utils.ts b/src/components/FlowEditor/nodes/shared/utils.ts
index 1f0d55a..f6d51da 100644
--- a/src/components/FlowEditor/nodes/shared/utils.ts
+++ b/src/components/FlowEditor/nodes/shared/utils.ts
@@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record | undefined, lang?: string) => string;
+
+/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
+export function getAcceptStringFromConfig(
+ config: Record
+): string {
+ const types = config.allowedTypes;
+ if (!Array.isArray(types) || types.length === 0) return '*';
+ return types.join(',');
+}
diff --git a/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx b/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx
index 406c6fc..68884f9 100644
--- a/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/start/FormStartNodeConfig.tsx
@@ -3,7 +3,7 @@
*/
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 { useLanguage } from '../../../../providers/language/LanguageContext';
diff --git a/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx b/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx
index 98d7bc9..68b18b0 100644
--- a/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/start/ScheduleStartNodeConfig.tsx
@@ -4,7 +4,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
-import type { NodeConfigRendererProps } from '../configs/types';
+import type { NodeConfigRendererProps } from '../shared/types';
import {
type ScheduleSpec,
type ScheduleMode,
diff --git a/src/components/FlowEditor/nodes/start/StartNodeConfig.tsx b/src/components/FlowEditor/nodes/start/StartNodeConfig.tsx
index 1bd9863..d9d2223 100644
--- a/src/components/FlowEditor/nodes/start/StartNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/start/StartNodeConfig.tsx
@@ -4,7 +4,7 @@
*/
import React from 'react';
-import type { NodeConfigRendererProps } from '../configs/types';
+import type { NodeConfigRendererProps } from '../shared/types';
import styles from '../../editor/Automation2FlowEditor.module.css';
const SCHEMA_EXAMPLE = `{
diff --git a/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx b/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx
index cb1a117..2e5c72e 100644
--- a/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx
+++ b/src/components/FlowEditor/nodes/switch/SwitchNodeConfig.tsx
@@ -4,7 +4,7 @@
*/
import React from 'react';
-import type { NodeConfigRendererProps } from '../configs/types';
+import type { NodeConfigRendererProps } from '../shared/types';
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { isRef, createValue } from '../shared/dataRef';
diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css
index da0bc83..2503343 100644
--- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css
+++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.module.css
@@ -341,6 +341,45 @@
box-shadow: none;
}
+/* Auto-translate button for multilingual fields */
+.translateBtn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 10px;
+ margin-top: 2px;
+ margin-bottom: 4px;
+ border: 1px solid var(--color-border, #E2E8F0);
+ border-radius: 4px;
+ background: var(--color-bg, #fff);
+ color: var(--color-secondary);
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.translateBtn:hover:not(:disabled) {
+ background: var(--color-secondary);
+ color: #fff;
+ border-color: var(--color-secondary);
+}
+
+.translateBtn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.translateBtnSpinner {
+ display: inline-block;
+ width: 12px;
+ height: 12px;
+ border: 1.5px solid currentColor;
+ border-top-color: transparent;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
/* Responsive design */
@media (max-width: 640px) {
.buttonGroup {
diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx
index 57e2688..cec6e8b 100644
--- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx
+++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import api from '../../../api';
import styles from './FormGeneratorForm.module.css';
@@ -16,13 +16,10 @@ import {
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
-// Helper function to detect TextMultilingual objects
-// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string }
const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
return false;
}
- // Check if it has 'en' property (required) and optionally other language codes
return 'en' in value && typeof value.en === 'string';
};
@@ -116,7 +113,7 @@ export function FormGeneratorForm>({
customValidator,
instanceId
}: FormGeneratorFormProps) {
- const { t } = useLanguage();
+ const { t, availableLanguages } = useLanguage();
const [formData, setFormData] = useState(data || {} as T);
const [errors, setErrors] = useState>({});
@@ -126,6 +123,7 @@ export function FormGeneratorForm>({
const [optionsCache, setOptionsCache] = useState>>({});
const [loadingOptions, setLoadingOptions] = useState>({});
const [submitting, setSubmitting] = useState(false);
+ const [translatingField, setTranslatingField] = useState(null);
// Track which option keys have been fetched or are being fetched (using ref to avoid re-renders)
const fetchedOrFetchingOptions = useRef>(new Set());
@@ -657,34 +655,70 @@ export function FormGeneratorForm>({
}
};
+ // Build multilingual language list dynamically from availableLanguages.
+ // 'en' is always first and required; remaining languages follow in DB order.
+ const multilingualLangs = useMemo(() => {
+ const base: { code: string; uiLabel: string; required: boolean }[] = [
+ { code: 'en', uiLabel: 'EN', required: true },
+ ];
+ for (const lang of availableLanguages) {
+ if (lang.code === 'en' || lang.code === 'xx') continue;
+ base.push({ code: lang.code, uiLabel: lang.code.toUpperCase(), required: false });
+ }
+ if (base.length === 1) {
+ base.push({ code: 'de', uiLabel: 'DE', required: false });
+ }
+ return base;
+ }, [availableLanguages]);
+
+ const _handleAutoTranslate = async (attrName: string, multilingualValue: Record) => {
+ const sourceLang = multilingualLangs.find(l => (multilingualValue[l.code] || '').trim())?.code;
+ if (!sourceLang) return;
+ const sourceText = (multilingualValue[sourceLang] || '').trim();
+ if (!sourceText) return;
+
+ const targetLangs = multilingualLangs.map(l => l.code).filter(c => c !== sourceLang);
+ if (!targetLangs.length) return;
+
+ setTranslatingField(attrName);
+ try {
+ const res = await api.post('/api/i18n/translate-field', {
+ sourceText,
+ sourceLang,
+ targetLangs,
+ });
+ const translations: Record = res.data?.translations || {};
+ const newValue = { ...multilingualValue };
+ for (const [lang, text] of Object.entries(translations)) {
+ newValue[lang] = text;
+ }
+ handleFieldChange(attrName, newValue);
+ } catch (err) {
+ console.error('Auto-translate failed:', err);
+ } finally {
+ setTranslatingField(null);
+ }
+ };
+
// Render multilingual field
const renderMultilingualField = (attr: AttributeDefinition) => {
const value = formData[attr.name] || { en: '' };
const hasError = errors[attr.name];
const isReadonly = mode === 'display' || attr.readonly || !isFieldEditableInMode(attr, mode);
-
- // Ensure value is a TextMultilingual object
+
const multilingualValue = isTextMultilingual(value) ? value : { en: typeof value === 'string' ? value : '' };
-
- const languages = [
- { code: 'en', label: 'EN', required: true },
- { code: 'ge', label: 'DE', required: false },
- { code: 'fr', label: 'FR', required: false },
- { code: 'it', label: 'IT', required: false }
- ];
-
+
const handleMultilingualChange = (langCode: string, langValue: string) => {
const newValue = { ...multilingualValue, [langCode]: langValue };
handleFieldChange(attr.name, newValue);
};
-
+
if (isReadonly) {
- // Display mode - show all languages
- const displayValues = languages
+ const displayValues = multilingualLangs
.filter(lang => multilingualValue[lang.code] && multilingualValue[lang.code].trim())
- .map(lang => `${lang.label}: ${multilingualValue[lang.code]}`)
+ .map(lang => `${lang.uiLabel}: ${multilingualValue[lang.code]}`)
.join(' | ');
-
+
return (
@@ -696,14 +730,14 @@ export function FormGeneratorForm>({
);
}
-
+
return (
{attr.label}
{attr.required && * }
- {languages.map(lang => (
+ {multilingualLangs.map(lang => (
>({
className={`${styles.fieldInput} ${hasError && lang.code === 'en' ? styles.fieldError : ''}`}
/>
- {lang.label}
+ {lang.uiLabel}
{lang.required && * }
))}
+ {multilingualLangs.length > 1 && (
+
(multilingualValue[l.code] || '').trim())}
+ onClick={() => _handleAutoTranslate(attr.name, multilingualValue)}
+ title={t('KI-Übersetzung: Füllt alle leeren Sprachen aus der ersten ausgefüllten Sprache.')}
+ >
+ {translatingField === attr.name
+ ? <> {t('Übersetze…')}>
+ : <>🌐 {t('In alle Sprachen übersetzen')}>
+ }
+
+ )}
{hasError &&
{hasError} }
);
diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
index 9623a68..9f39c33 100644
--- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
+++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx
@@ -78,51 +78,32 @@ import api from '../../../api';
// FK Cache type: maps fkSource -> { id -> displayLabel }
type FkCacheType = Record
>;
-// Helper function to detect TextMultilingual objects
-// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string }
const isTextMultilingual = (value: any): boolean => {
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
return false;
}
- // Check if it has 'en' property (required) and optionally other language codes
return 'en' in value && typeof value.en === 'string';
};
-// Helper function to format TextMultilingual for display
const formatTextMultilingual = (value: any, currentLanguage?: string): string => {
if (!isTextMultilingual(value)) {
return String(value);
}
-
- // Map language codes (backend uses 'ge' for German, frontend might use 'de')
- const languageMap: Record = {
- 'de': 'ge',
- 'en': 'en',
- 'fr': 'fr',
- 'it': 'it'
- };
-
- // Try to get value for current language
- if (currentLanguage) {
- const backendLang = languageMap[currentLanguage] || currentLanguage;
- if (value[backendLang] && typeof value[backendLang] === 'string' && value[backendLang].trim()) {
- return value[backendLang];
- }
+
+ if (currentLanguage && value[currentLanguage] && typeof value[currentLanguage] === 'string' && value[currentLanguage].trim()) {
+ return value[currentLanguage];
}
-
- // Fallback to English (required field)
+
if (value.en && typeof value.en === 'string' && value.en.trim()) {
return value.en;
}
-
- // If no English, try other languages
- const languages = ['ge', 'fr', 'it'];
- for (const lang of languages) {
- if (value[lang] && typeof value[lang] === 'string' && value[lang].trim()) {
- return value[lang];
+
+ for (const key of Object.keys(value)) {
+ if (key !== 'en' && value[key] && typeof value[key] === 'string' && value[key].trim()) {
+ return value[key];
}
}
-
+
return '-';
};
@@ -335,11 +316,7 @@ export function FormGeneratorTable>({
const { t, currentLanguage: contextLanguage } = useLanguage();
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
const onDeleteMultiple = onDeleteMultipleProp ?? (onDelete ? (rows: T[]) => rows.forEach((r) => onDelete(r)) : undefined);
- // Map frontend language codes (de/en/fr) to backend codes (ge/en/fr) for multilingual field resolution
- const currentLanguage = useMemo(() => {
- const langMap: Record = { 'de': 'ge', 'en': 'en', 'fr': 'fr', 'it': 'it' };
- return langMap[contextLanguage] || contextLanguage || 'en';
- }, [contextLanguage]);
+ const currentLanguage = useMemo(() => contextLanguage || 'en', [contextLanguage]);
// Use provided columns from Pydantic attribute definitions
// NO AUTO-DETECTION - columns must come from backend attribute definitions
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
@@ -617,25 +594,8 @@ export function FormGeneratorTable>({
// Object - check for TextMultilingual (has 'en' key)
if (typeof fieldValue === 'object' && fieldValue !== null) {
- // TextMultilingual: { en: "...", ge: "...", fr: "...", it: "..." }
if ('en' in fieldValue) {
- // Map frontend language codes to backend codes
- const langMap: Record = { 'de': 'ge', 'en': 'en', 'fr': 'fr', 'it': 'it' };
- const backendLang = langMap[language] || language;
-
- // Try current language first, then fallback
- if (fieldValue[backendLang] && typeof fieldValue[backendLang] === 'string' && fieldValue[backendLang].trim()) {
- return fieldValue[backendLang];
- }
- if (fieldValue.en && typeof fieldValue.en === 'string' && fieldValue.en.trim()) {
- return fieldValue.en;
- }
- // Try other languages
- for (const lang of ['ge', 'fr', 'it']) {
- if (fieldValue[lang] && typeof fieldValue[lang] === 'string' && fieldValue[lang].trim()) {
- return fieldValue[lang];
- }
- }
+ return formatTextMultilingual(fieldValue, language);
}
// Other objects → try to stringify
diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx
index 9eb3cee..bda6857 100644
--- a/src/components/Navigation/MandateNavigation.tsx
+++ b/src/components/Navigation/MandateNavigation.tsx
@@ -46,12 +46,13 @@ type NavTranslateFn = (key: string, params?: Record) =>
// =============================================================================
/**
- * Convert a NavigationItem (from static block) to TreeNodeItem
+ * Convert a NavigationItem (from static block) to TreeNodeItem.
+ * Labels from the backend are German i18n keys — translate via t().
*/
-function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
+function navigationItemToTreeNode(item: NavigationItem, tr: NavTranslateFn): TreeNodeItem {
return {
id: item.objectKey,
- label: item.uiLabel,
+ label: tr(item.uiLabel),
icon: getPageIcon(item.uiComponent),
path: item.uiPath,
};
@@ -65,23 +66,25 @@ function _staticItemsToTreeNode(
id: string,
label: string,
items: NavigationItem[],
+ tr: NavTranslateFn,
defaultExpanded: boolean = true,
): TreeNodeItem {
return {
id,
label,
- children: items.map(navigationItemToTreeNode),
+ children: items.map(i => navigationItemToTreeNode(i, tr)),
defaultExpanded,
};
}
/**
- * Convert a FeatureView to TreeNodeItem
+ * Convert a FeatureView to TreeNodeItem.
+ * View labels are German i18n keys — translate via t().
*/
-function featureViewToTreeNode(view: FeatureView): TreeNodeItem {
+function featureViewToTreeNode(view: FeatureView, tr: NavTranslateFn): TreeNodeItem {
return {
id: view.objectKey,
- label: view.uiLabel,
+ label: tr(view.uiLabel),
path: view.uiPath,
};
}
@@ -98,7 +101,7 @@ function featureInstanceToTreeNode(
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
tr: NavTranslateFn,
): TreeNodeItem {
- const children = instance.views.map(featureViewToTreeNode);
+ const children = instance.views.map(v => featureViewToTreeNode(v, tr));
const renameAction = instance.isAdmin && onRename ? (
{
export const MandateNavigation: React.FC = () => {
const { t } = useLanguage();
- const { blocks, loading, refresh } = useNavigation('de');
+ const { blocks, loading, refresh } = useNavigation();
const { prompt, PromptDialog } = usePrompt();
const { showWarning } = useToast();
@@ -249,14 +252,14 @@ export const MandateNavigation: React.FC = () => {
if (systemBlock) {
const children: TreeNodeItem[] = [];
for (const item of systemBlock.items) {
- children.push(navigationItemToTreeNode(item));
+ children.push(navigationItemToTreeNode(item, t));
}
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
for (const sg of systemBlock.subgroups) {
children.push({
id: sg.id,
- label: sg.title,
- children: sg.items.map(navigationItemToTreeNode),
+ label: t(sg.title),
+ children: sg.items.map(i => navigationItemToTreeNode(i, t)),
defaultExpanded: true,
});
}
@@ -285,8 +288,8 @@ export const MandateNavigation: React.FC = () => {
if (items.length > 0) items.push({ type: 'separator' });
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
id: sg.id,
- label: sg.title,
- children: sg.items.map(navigationItemToTreeNode),
+ label: t(sg.title),
+ children: sg.items.map(i => navigationItemToTreeNode(i, t)),
defaultExpanded: false,
}));
items.push({
@@ -297,7 +300,7 @@ export const MandateNavigation: React.FC = () => {
});
} else if (adminItems.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
- items.push(_staticItemsToTreeNode('administration', t('Administration'), adminItems, false));
+ items.push(_staticItemsToTreeNode('administration', t('Administration'), adminItems, t, false));
}
return items;
diff --git a/src/components/QuickActionBoard/QuickActionBoard.module.css b/src/components/QuickActionBoard/QuickActionBoard.module.css
new file mode 100644
index 0000000..83886f4
--- /dev/null
+++ b/src/components/QuickActionBoard/QuickActionBoard.module.css
@@ -0,0 +1,118 @@
+/* QuickActionBoard — Quick Action card grid */
+
+.board {
+ margin-top: 1.5rem;
+}
+
+.boardTitle {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin: 0 0 0.75rem 0;
+ color: var(--text-primary, #1a1a2e);
+}
+
+.categorySection {
+ margin-bottom: 1.25rem;
+}
+
+.categoryTitle {
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-secondary, #6c7293);
+ margin: 0 0 0.5rem 0;
+}
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+
+.card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.35rem;
+ padding: 1rem;
+ border: 1px solid var(--border-light, #e4e6ef);
+ border-radius: 10px;
+ background: var(--bg-card, #ffffff);
+ cursor: pointer;
+ transition: box-shadow 0.15s ease, transform 0.1s ease, border-color 0.15s ease;
+ text-align: left;
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+ outline: none;
+}
+
+.card:hover {
+ box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
+ transform: translateY(-1px);
+ border-color: var(--border-active, #b5b5c3);
+}
+
+.card:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-primary, #4361ee);
+}
+
+.card:active {
+ transform: translateY(0);
+}
+
+.actionIcon {
+ font-size: 1.5rem;
+ line-height: 1;
+ margin-bottom: 0.15rem;
+}
+
+.actionLabel {
+ font-size: 0.9rem;
+ font-weight: 600;
+ color: var(--text-primary, #1a1a2e);
+}
+
+.actionDescription {
+ font-size: 0.78rem;
+ color: var(--text-secondary, #6c7293);
+ line-height: 1.35;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+/* Skeleton loading */
+
+.cardSkeleton {
+ pointer-events: none;
+ animation: skeletonPulse 1.4s ease-in-out infinite;
+}
+
+.skeletonIcon {
+ width: 2rem;
+ height: 2rem;
+ border-radius: 6px;
+ background: var(--skeleton-bg, #e4e6ef);
+}
+
+.skeletonText {
+ width: 70%;
+ height: 0.9rem;
+ border-radius: 4px;
+ background: var(--skeleton-bg, #e4e6ef);
+}
+
+.skeletonTextShort {
+ width: 50%;
+ height: 0.75rem;
+ border-radius: 4px;
+ background: var(--skeleton-bg, #e4e6ef);
+}
+
+@keyframes skeletonPulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.45; }
+}
diff --git a/src/components/QuickActionBoard/QuickActionBoard.tsx b/src/components/QuickActionBoard/QuickActionBoard.tsx
new file mode 100644
index 0000000..064565b
--- /dev/null
+++ b/src/components/QuickActionBoard/QuickActionBoard.tsx
@@ -0,0 +1,166 @@
+/**
+ * QuickActionBoard — reusable card grid for feature dashboards.
+ *
+ * Renders a set of Quick Action cards grouped by category.
+ * Each card dispatches via the onDispatch callback; the parent
+ * decides what happens (navigate to workspace, trigger workflow, etc.).
+ */
+
+import React from 'react';
+import styles from './QuickActionBoard.module.css';
+import { useLanguage } from '../../providers/language/LanguageContext';
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+export interface QuickAction {
+ id: string;
+ label: string;
+ description: string;
+ icon: string;
+ color: string;
+ category: string;
+ actionType: 'agentPrompt' | 'workflow' | 'link';
+ config: Record;
+ sortOrder: number;
+}
+
+export interface QuickActionCategory {
+ id: string;
+ label: string;
+ sortOrder: number;
+}
+
+export interface QuickActionBoardProps {
+ actions: QuickAction[];
+ categories?: QuickActionCategory[];
+ onDispatch: (action: QuickAction) => void;
+ loading?: boolean;
+ grouped?: boolean;
+}
+
+// ============================================================================
+// ICON MAP (mdi name → unicode/emoji fallback)
+// ============================================================================
+
+const _ICON_MAP: Record = {
+ 'mdi-file-document-check-outline': '\uD83D\uDCCB',
+ 'mdi-sync': '\uD83D\uDD04',
+ 'mdi-chart-bar': '\uD83D\uDCCA',
+ 'mdi-view-dashboard-outline': '\uD83D\uDCF0',
+ 'mdi-cash-multiple': '\uD83D\uDCB0',
+ 'mdi-clipboard-check-outline': '\u2705',
+ 'mdi-chart-timeline-variant': '\uD83D\uDCC8',
+ 'mdi-camera-document-outline': '\uD83D\uDCF7',
+};
+
+function _renderIcon(icon: string, color: string): React.ReactNode {
+ const fallback = _ICON_MAP[icon] || '\u26A1';
+ return (
+
+ {fallback}
+
+ );
+}
+
+// ============================================================================
+// COMPONENT
+// ============================================================================
+
+export const QuickActionBoard: React.FC = ({
+ actions,
+ categories,
+ onDispatch,
+ loading = false,
+ grouped = true,
+}) => {
+ const { t } = useLanguage();
+
+ if (loading) {
+ return (
+
+
{t('quickActions.title')}
+
+ {[1, 2, 3, 4].map((i) => (
+
+ ))}
+
+
+ );
+ }
+
+ if (!actions || actions.length === 0) {
+ return null;
+ }
+
+ const _handleClick = (action: QuickAction) => (e: React.MouseEvent) => {
+ e.preventDefault();
+ onDispatch(action);
+ };
+
+ if (!grouped || !categories || categories.length === 0) {
+ return (
+
+
{t('quickActions.title')}
+
+ {actions.map((action) => (
+
+ {_renderIcon(action.icon, action.color)}
+ {action.label}
+ {action.description}
+
+ ))}
+
+
+ );
+ }
+
+ const sortedCategories = [...categories].sort((a, b) => a.sortOrder - b.sortOrder);
+ const actionsByCategory = new Map();
+ for (const action of actions) {
+ const cat = action.category || '_uncategorized';
+ if (!actionsByCategory.has(cat)) actionsByCategory.set(cat, []);
+ actionsByCategory.get(cat)!.push(action);
+ }
+
+ return (
+
+
{t('quickActions.title')}
+ {sortedCategories.map((cat) => {
+ const catActions = actionsByCategory.get(cat.id);
+ if (!catActions || catActions.length === 0) return null;
+ return (
+
+
{cat.label}
+
+ {catActions.map((action) => (
+
+ {_renderIcon(action.icon, action.color)}
+ {action.label}
+ {action.description}
+
+ ))}
+
+
+ );
+ })}
+
+ );
+};
+
+export default QuickActionBoard;
diff --git a/src/components/QuickActionBoard/index.ts b/src/components/QuickActionBoard/index.ts
new file mode 100644
index 0000000..0878e96
--- /dev/null
+++ b/src/components/QuickActionBoard/index.ts
@@ -0,0 +1,2 @@
+export { QuickActionBoard, default } from './QuickActionBoard';
+export type { QuickAction, QuickActionCategory, QuickActionBoardProps } from './QuickActionBoard';
diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx
index 2d4c614..3690b7c 100644
--- a/src/config/pageRegistry.tsx
+++ b/src/config/pageRegistry.tsx
@@ -22,7 +22,7 @@ import {
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
- FaFileContract, FaRobot, FaGlobe,
+ FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
} from 'react-icons/fa';
// =============================================================================
@@ -92,6 +92,8 @@ export const PAGE_ICONS: Record = {
'page.feature.trustee.scan-upload': ,
'page.feature.trustee.instance-roles': ,
'page.feature.trustee.settings': ,
+ 'page.feature.trustee.analyse': ,
+ 'page.feature.trustee.abschluss': ,
// Feature pages - Real Estate
'page.feature.realestate.projects': ,
diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts
index 2eaf47b..d8691bb 100644
--- a/src/hooks/useNavigation.ts
+++ b/src/hooks/useNavigation.ts
@@ -1,20 +1,11 @@
/**
* useNavigation Hook
*
- * Fetches the navigation structure from the new Navigation API.
- * The backend provides a blocks-based structure with static and dynamic blocks.
+ * Fetches the navigation structure from the Navigation API.
+ * Backend provides blocks with German base texts as labels (i18n keys).
+ * The UI translates them via t().
*
- * API: GET /api/navigation?language=de
- *
- * Response structure (gemäss Navigation-API-Konzept):
- * {
- * "language": "de",
- * "blocks": [
- * { "type": "static", "id": "system", "title": "SYSTEM", "order": 10, "items": [...] },
- * { "type": "dynamic", "id": "features", "title": "MEINE FEATURES", "order": 15, "mandates": [...] },
- * ...
- * ]
- * }
+ * API: GET /api/navigation
*/
import { useState, useEffect, useCallback } from 'react';
@@ -99,7 +90,6 @@ export type NavigationBlock = StaticBlock | DynamicBlock;
/** API Response structure */
export interface NavigationResponse {
- language: string;
blocks: NavigationBlock[];
}
@@ -135,7 +125,7 @@ function isDynamicBlock(block: NavigationBlock): block is DynamicBlock {
// HOOK
// =============================================================================
-export function useNavigation(language: string = 'de'): UseNavigationReturn {
+export function useNavigation(): UseNavigationReturn {
const [blocks, setBlocks] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -145,14 +135,8 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn {
setError(null);
try {
- // New API endpoint: /api/navigation (without /system prefix)
- const response = await api.get(
- `/api/navigation?language=${language}`
- );
-
- // Blocks are already sorted by order from backend
+ const response = await api.get('/api/navigation');
setBlocks(response.data.blocks || []);
-
} catch (err: unknown) {
const errorMsg = err instanceof Error
? err.message
@@ -163,7 +147,7 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn {
} finally {
setLoading(false);
}
- }, [language]);
+ }, []);
useEffect(() => {
fetchNavigation();
diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx
index 2592f31..6e0a4ce 100644
--- a/src/layouts/MainLayout.tsx
+++ b/src/layouts/MainLayout.tsx
@@ -13,6 +13,7 @@ import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
+import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
@@ -20,6 +21,7 @@ import { useLanguage } from '../providers/language/LanguageContext';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
+const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
@@ -34,7 +36,8 @@ const MainLayoutInner: React.FC = () => {
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname);
- const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible;
+ const isLanguagesKeepAliveVisible = _ADMIN_LANGUAGES_RE.test(location.pathname);
+ const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible || isLanguagesKeepAliveVisible;
// Features laden beim Mount
useEffect(() => {
@@ -120,6 +123,7 @@ const MainLayoutInner: React.FC = () => {
+
> = {
'expense-import': TrusteeExpenseImportView,
'scan-upload': TrusteeScanUploadView,
settings: TrusteeAccountingSettingsView,
+ analyse: TrusteeAnalyseView,
+ abschluss: TrusteeAbschlussView,
},
chatworkflow: {
dashboard: ChatworkflowDashboard,
@@ -180,8 +181,7 @@ interface FeatureViewPageProps {
}
export const FeatureViewPage: React.FC
= ({ view }) => {
- const { instance, featureCode, instanceId, isValid } = useCurrentInstance();
- const { blocks } = useNavigation();
+ const { instance, featureCode, isValid } = useCurrentInstance();
// Berechtigungs-Check
const viewCode = `${featureCode}-${view}`;
@@ -232,9 +232,6 @@ export const FeatureViewPage: React.FC = ({ view }) => {
return null;
}
- // GraphicalEditor sub-pages have their own headers with actions; skip the wrapper title.
- const _skipViewHeader = featureCode === 'graphicalEditor';
-
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {
@@ -246,29 +243,8 @@ export const FeatureViewPage: React.FC = ({ view }) => {
return ;
}
- let viewLabel = view;
- for (const block of blocks) {
- if (block.type !== 'dynamic') continue;
- for (const mandate of (block as any).mandates || []) {
- for (const feat of mandate.features || []) {
- for (const inst of feat.instances || []) {
- if (inst.id !== instanceId) continue;
- const vDef: FeatureViewDef | undefined = inst.views?.find(
- (v: FeatureViewDef) => v.uiComponent?.endsWith(`.${view}`)
- );
- if (vDef?.uiLabel) viewLabel = vDef.uiLabel;
- }
- }
- }
- }
-
return (
- {!_skipViewHeader && (
-
- )}
diff --git a/src/pages/admin/AdminLanguagesKeepAlive.tsx b/src/pages/admin/AdminLanguagesKeepAlive.tsx
new file mode 100644
index 0000000..07a02b6
--- /dev/null
+++ b/src/pages/admin/AdminLanguagesKeepAlive.tsx
@@ -0,0 +1,35 @@
+/**
+ * AdminLanguagesKeepAlive
+ *
+ * Keeps the AdminLanguagesPage mounted across route changes so that
+ * long-running AI translation progress, table state, and selections
+ * survive when the user navigates away and returns.
+ */
+
+import React from 'react';
+import { AdminLanguagesPage } from './AdminLanguagesPage';
+
+interface AdminLanguagesKeepAliveProps {
+ isVisible: boolean;
+}
+
+export const AdminLanguagesKeepAlive: React.FC
= ({ isVisible }) => {
+ return (
+
+ );
+};
+
+export default AdminLanguagesKeepAlive;
diff --git a/src/pages/admin/AdminLanguagesPage.tsx b/src/pages/admin/AdminLanguagesPage.tsx
index a6da2b6..a8099e8 100644
--- a/src/pages/admin/AdminLanguagesPage.tsx
+++ b/src/pages/admin/AdminLanguagesPage.tsx
@@ -15,6 +15,8 @@ type LangRow = {
label: string;
status: string;
entriesCount: number;
+ uiCount: number;
+ gatewayCount: number;
};
type ProgressInfo = {
@@ -40,7 +42,9 @@ function _getColumns(t: (key: string) => string): ColumnConfig[] {
{ key: 'id', label: t('adminLanguages.code'), type: 'text', sortable: true, filterable: true, width: 90 },
{ key: 'label', label: t('adminLanguages.bezeichnung'), type: 'text', sortable: true, filterable: true, width: 200 },
{ key: 'status', label: t('adminLanguages.status'), type: 'text', sortable: true, filterable: true, width: 120 },
- { key: 'entriesCount', label: t('adminLanguages.eintraege'), type: 'number', sortable: true, width: 100 },
+ { key: 'uiCount', label: t('adminLanguages.ui'), type: 'number', sortable: true, width: 80 },
+ { key: 'gatewayCount', label: t('adminLanguages.api'), type: 'number', sortable: true, width: 80 },
+ { key: 'entriesCount', label: t('adminLanguages.total'), type: 'number', sortable: true, width: 80 },
];
}
@@ -259,6 +263,7 @@ export const AdminLanguagesPage: React.FC = () => {
const [error, setError] = useState(null);
const [addCode, setAddCode] = useState('');
const [progress, setProgress] = useState(null);
+ const [search, setSearch] = useState('');
const busyRef = useRef(false);
const _load = useCallback(async () => {
@@ -273,6 +278,8 @@ export const AdminLanguagesPage: React.FC = () => {
label: r.label || r.code,
status: r.status || '',
entriesCount: r.entriesCount ?? 0,
+ uiCount: r.uiCount ?? 0,
+ gatewayCount: r.gatewayCount ?? 0,
})),
);
} catch (e: any) {
@@ -286,6 +293,18 @@ export const AdminLanguagesPage: React.FC = () => {
_load();
}, [_load]);
+ const displayRows = useMemo(() => {
+ const term = search.trim().toLowerCase();
+ const filtered = term
+ ? rows.filter((r) => r.id.toLowerCase().includes(term) || r.label.toLowerCase().includes(term) || r.status.toLowerCase().includes(term))
+ : rows;
+ return [...filtered].sort((a, b) => {
+ if (a.id === 'xx') return -1;
+ if (b.id === 'xx') return 1;
+ return a.id.localeCompare(b.id);
+ });
+ }, [rows, search]);
+
const existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
const addChoices = useMemo(() => {
@@ -658,6 +677,16 @@ export const AdminLanguagesPage: React.FC = () => {
+
+
+
+
setSearch(e.target.value)}
+ placeholder={t('Suche…')}
+ style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
+ />
{t('Alle aktualisieren')}
@@ -688,11 +717,12 @@ export const AdminLanguagesPage: React.FC = () => {
{
return (
{/* Chat History Sidebar */}
-
-
-
Konversationen
+
+
{
return (
-
Coaching-Einstellungen
-
{error &&
{error}
}
{success &&
{success}
}
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
index 6c1996f..33acdaa 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
@@ -102,7 +102,6 @@ export const GraphicalEditorPage: React.FC
= ({
if (!instanceId) {
return (
-
{t('graphicalEditor.graphicalEditor')}
{t('graphicalEditor.keineFeatureinstanzGefunden')}
);
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
index b8045e0..e8bc3fb 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
@@ -218,7 +218,6 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
-
Workflow-Vorlagen
Vorlagen verwalten, kopieren und freigeben
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
index 269c9e7..cc014f7 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
@@ -257,7 +257,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
-
{t('graphicalEditorWorkflows.gespeicherteWorkflows')}
Workflows verwalten, ausführen und bearbeiten
diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
index 3ba419d..54fad8b 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx
@@ -190,7 +190,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
if (!instanceId) {
return (
-
Tasks
{t('graphicalEditorWorkflowsTasks.keineFeatureinstanzGefunden')}
);
@@ -209,8 +208,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
-
Tasks
-
{/* Open tasks */}
diff --git a/src/pages/views/trustee/TrusteeAbschlussView.tsx b/src/pages/views/trustee/TrusteeAbschlussView.tsx
new file mode 100644
index 0000000..ffe62a3
--- /dev/null
+++ b/src/pages/views/trustee/TrusteeAbschlussView.tsx
@@ -0,0 +1,291 @@
+/**
+ * TrusteeAbschlussView
+ *
+ * Tab-based closing/review page. Currently one tab (year-end check),
+ * extensible for future use cases. Follows the same pattern as
+ * TrusteeAnalyseView: loads the bootstrapped workflow, executes it,
+ * and shows pipeline status inline.
+ */
+
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
+import { useToast } from '../../../contexts/ToastContext';
+import api from '../../../api';
+import styles from './TrusteeViews.module.css';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+
+// ---------------------------------------------------------------------------
+// Tab definitions
+// ---------------------------------------------------------------------------
+
+interface TabDef {
+ id: string;
+ templateTag: string;
+ icon: string;
+ color: string;
+}
+
+const _TABS: TabDef[] = [
+ { id: 'year-end', templateTag: 'template:trustee-year-end-check', icon: '\u2705', color: '#795548' },
+];
+
+const _TAB_LABELS: Record> = {
+ 'year-end': { de: 'Jahresabschluss prüfen', en: 'Year-End Review', fr: 'Contrôle de clôture' },
+};
+
+const _TAB_DESCRIPTIONS: Record> = {
+ 'year-end': {
+ de: 'Automatische Prüfungen für den Jahresabschluss: Saldovalidierung, Vorjahresvergleich, gesetzliche Checks.',
+ en: 'Automated year-end review: balance validation, prior-year comparison, legal compliance checks.',
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface WorkflowSummary {
+ id: string;
+ label: string;
+ tags: string[];
+}
+
+type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export const TrusteeAbschlussView: React.FC = () => {
+ const { t, currentLanguage } = useLanguage();
+ const lang = currentLanguage || 'de';
+ const { instanceId } = useCurrentInstance();
+ const { showSuccess, showError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const activeTab = searchParams.get('tab') || _TABS[0].id;
+ const _setActiveTab = useCallback((tab: string) => {
+ setSearchParams({ tab }, { replace: true });
+ }, [setSearchParams]);
+
+ const [workflows, setWorkflows] = useState([]);
+ const [workflowsLoading, setWorkflowsLoading] = useState(true);
+
+ const [runState, setRunState] = useState('idle');
+ const [runId, setRunId] = useState(null);
+ const [runSummary, setRunSummary] = useState('');
+ const [runError, setRunError] = useState(null);
+ const pollTimerRef = useRef(null);
+ const isPollingRef = useRef(false);
+
+ useEffect(() => {
+ if (!instanceId) return;
+ const _load = async () => {
+ setWorkflowsLoading(true);
+ try {
+ const res = await api.get(`/api/workflows/${instanceId}/workflows`);
+ const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
+ id: w.id,
+ label: w.label,
+ tags: w.tags || [],
+ }));
+ setWorkflows(items);
+ } catch {
+ setWorkflows([]);
+ } finally {
+ setWorkflowsLoading(false);
+ }
+ };
+ _load();
+ }, [instanceId]);
+
+ const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
+ const tabDef = _TABS.find((t) => t.id === tab);
+ if (!tabDef) return undefined;
+ return workflows.find((w) => w.tags.includes(tabDef.templateTag));
+ }, [workflows]);
+
+ const _stopPolling = useCallback(() => {
+ if (pollTimerRef.current !== null) {
+ window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ isPollingRef.current = false;
+ }, []);
+
+ const _pollRun = useCallback(async (rid: string) => {
+ if (!instanceId || !rid || isPollingRef.current) return;
+ isPollingRef.current = true;
+ try {
+ const res = await api.get(`/api/workflows/${instanceId}/runs/${rid}/steps`);
+ const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
+ const completed = steps.filter((s) => s.status === 'completed');
+ const failed = steps.filter((s) => s.status === 'failed');
+ const running = steps.filter((s) => s.status === 'running');
+
+ setRunSummary(`${completed.length}/${steps.length} ${t('trusteeAbschluss.stepsCompleted', 'Schritte abgeschlossen')}`);
+
+ if (failed.length > 0) {
+ const errMsg = failed[failed.length - 1].error || 'Step failed';
+ setRunState('error');
+ setRunError(errMsg);
+ _stopPolling();
+ showError('Pipeline error', errMsg);
+ return;
+ }
+ if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
+ setRunState('completed');
+ _stopPolling();
+ showSuccess(t('trusteeAbschluss.completed', 'Abgeschlossen'), t('trusteeAbschluss.workflowDone', 'Prüfungs-Workflow erfolgreich beendet.'));
+ return;
+ }
+ setRunState('running');
+ } catch (err: any) {
+ if (err?.response?.status === 404) { setRunState('running'); return; }
+ setRunState('error');
+ setRunError(err.message || 'Polling failed');
+ _stopPolling();
+ } finally {
+ isPollingRef.current = false;
+ }
+ }, [instanceId, showError, showSuccess, _stopPolling, t]);
+
+ useEffect(() => {
+ if (!instanceId || !runId || (runState !== 'running' && runState !== 'starting')) return;
+ void _pollRun(runId);
+ pollTimerRef.current = window.setInterval(() => { void _pollRun(runId); }, 3000);
+ return () => { _stopPolling(); };
+ }, [instanceId, runId, runState, _pollRun, _stopPolling]);
+
+ useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
+
+ useEffect(() => {
+ _stopPolling();
+ setRunState('idle');
+ setRunId(null);
+ setRunSummary('');
+ setRunError(null);
+ }, [activeTab, _stopPolling]);
+
+ const _handleExecute = useCallback(async () => {
+ const wf = _findWorkflow(activeTab);
+ if (!wf || !instanceId) {
+ showError('Error', t('trusteeAbschluss.noWorkflow', 'Kein Workflow für diesen Tab gefunden.'));
+ return;
+ }
+ setRunState('starting');
+ setRunError(null);
+ setRunSummary(t('trusteeAbschluss.starting', 'Workflow wird gestartet...'));
+ try {
+ const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
+ const rid = res?.data?.runId;
+ if (rid) {
+ setRunId(rid);
+ setRunState('running');
+ setRunSummary(`Run ${rid.slice(0, 8)} ${t('trusteeAbschluss.started', 'gestartet')}`);
+ } else if (res?.data?.success) {
+ setRunState('completed');
+ setRunSummary(t('trusteeAbschluss.completedSync', 'Workflow synchron abgeschlossen.'));
+ showSuccess(t('trusteeAbschluss.completed', 'Abgeschlossen'), t('trusteeAbschluss.workflowDone', 'Prüfungs-Workflow erfolgreich beendet.'));
+ } else {
+ throw new Error(res?.data?.error || 'Unexpected response');
+ }
+ } catch (err: any) {
+ const msg = err?.response?.data?.detail || err.message || 'Failed to start workflow';
+ setRunState('error');
+ setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
+ showError('Error', typeof msg === 'string' ? msg : JSON.stringify(msg));
+ }
+ }, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
+
+ const currentTab = _TABS.find((t) => t.id === activeTab) || _TABS[0];
+ const currentWorkflow = _findWorkflow(activeTab);
+
+ return (
+
+
+
{t('trusteeAbschluss.title', 'Abschluss & Prüfung')}
+
+ {/* Tab bar */}
+ {_TABS.length > 1 && (
+
+ {_TABS.map((tab) => (
+ _setActiveTab(tab.id)}
+ style={{
+ padding: '0.625rem 1rem',
+ border: 'none',
+ borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
+ fontWeight: activeTab === tab.id ? 600 : 400,
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ marginBottom: '-2px',
+ }}
+ >
+ {tab.icon}
+ {_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id}
+
+ ))}
+
+ )}
+
+ {/* Tab content */}
+
+
+ {_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''}
+
+
+ {workflowsLoading ? (
+
{t('trusteeAbschluss.loadingWorkflows', 'Workflows werden geladen...')}
+ ) : !currentWorkflow ? (
+
+
{t('trusteeAbschluss.noWorkflowInfo', 'Für diesen Tab wurde kein Workflow in der Instanz gefunden. Der Workflow wird beim Erstellen der Instanz automatisch angelegt.')}
+
+ ) : (
+ <>
+
+
{currentTab.icon}
+
+
{currentWorkflow.label}
+
+ Workflow ID: {currentWorkflow.id.slice(0, 8)}...
+
+
+
+
+
+ {runState === 'starting' || runState === 'running'
+ ? t('trusteeAbschluss.running', 'Läuft...')
+ : t('trusteeAbschluss.execute', 'Prüfung starten')}
+
+ >
+ )}
+
+ {runState !== 'idle' && (
+
+
{t('trusteeAbschluss.status', 'Status')}: {' '}
+ {runState === 'starting' && t('trusteeAbschluss.startingLabel', 'Wird gestartet...')}
+ {runState === 'running' && t('trusteeAbschluss.runningLabel', 'Läuft')}
+ {runState === 'completed' && t('trusteeAbschluss.completedLabel', 'Abgeschlossen')}
+ {runState === 'error' && t('trusteeAbschluss.errorLabel', 'Fehler')}
+ {runSummary &&
{runSummary}
}
+ {runError &&
{runError}
}
+
+ )}
+
+
+
+ );
+};
+
+export default TrusteeAbschlussView;
diff --git a/src/pages/views/trustee/TrusteeAnalyseView.tsx b/src/pages/views/trustee/TrusteeAnalyseView.tsx
new file mode 100644
index 0000000..f2862a9
--- /dev/null
+++ b/src/pages/views/trustee/TrusteeAnalyseView.tsx
@@ -0,0 +1,304 @@
+/**
+ * TrusteeAnalyseView
+ *
+ * Tab-based analysis page. Each tab maps to a bootstrapped template workflow
+ * (created from TEMPLATE_WORKFLOWS when the feature instance was set up).
+ * The workflow is loaded from the instance, executed via the workflow engine,
+ * and results/status are shown inline with polling.
+ */
+
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
+import { useToast } from '../../../contexts/ToastContext';
+import api from '../../../api';
+import styles from './TrusteeViews.module.css';
+import { useLanguage } from '../../../providers/language/LanguageContext';
+
+// ---------------------------------------------------------------------------
+// Tab definitions
+// ---------------------------------------------------------------------------
+
+interface TabDef {
+ id: string;
+ templateTag: string;
+ icon: string;
+ color: string;
+}
+
+const _TABS: TabDef[] = [
+ { id: 'budget', templateTag: 'template:trustee-budget-comparison', icon: '\uD83D\uDCCA', color: '#2196F3' },
+ { id: 'kpi', templateTag: 'template:trustee-kpi-dashboard', icon: '\uD83D\uDCF0', color: '#9C27B0' },
+ { id: 'cashflow', templateTag: 'template:trustee-cashflow', icon: '\uD83D\uDCB0', color: '#009688' },
+ { id: 'forecast', templateTag: 'template:trustee-forecast', icon: '\uD83D\uDCC8', color: '#E91E63' },
+];
+
+const _TAB_LABELS: Record> = {
+ budget: { de: 'Budget-Vergleich', en: 'Budget Comparison', fr: 'Comparaison budgétaire' },
+ kpi: { de: 'KPI-Dashboard', en: 'KPI Dashboard', fr: 'Tableau de bord KPI' },
+ cashflow: { de: 'Cashflow-Rechnung', en: 'Cash Flow Statement', fr: 'Flux de trésorerie' },
+ forecast: { de: 'Prognose', en: 'Forecast', fr: 'Prévision' },
+};
+
+const _TAB_DESCRIPTIONS: Record> = {
+ budget: { de: 'Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel', en: 'Compare actuals vs. budget from Excel' },
+ kpi: { de: 'Kennzahlen berechnen und visualisieren', en: 'Calculate and visualize key metrics' },
+ cashflow: { de: 'Cashflow berechnen und analysieren', en: 'Calculate and analyze cash flow' },
+ forecast: { de: 'Trend-Analyse und Prognose der nächsten Monate', en: 'Trend analysis and forecast for coming months' },
+};
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+interface WorkflowSummary {
+ id: string;
+ label: string;
+ tags: string[];
+}
+
+type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+export const TrusteeAnalyseView: React.FC = () => {
+ const { t, currentLanguage } = useLanguage();
+ const lang = currentLanguage || 'de';
+ const { instanceId } = useCurrentInstance();
+ const { showSuccess, showError } = useToast();
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ const activeTab = searchParams.get('tab') || _TABS[0].id;
+ const _setActiveTab = useCallback((tab: string) => {
+ setSearchParams({ tab }, { replace: true });
+ }, [setSearchParams]);
+
+ const [workflows, setWorkflows] = useState([]);
+ const [workflowsLoading, setWorkflowsLoading] = useState(true);
+
+ const [runState, setRunState] = useState('idle');
+ const [runId, setRunId] = useState(null);
+ const [runSummary, setRunSummary] = useState('');
+ const [runError, setRunError] = useState(null);
+ const pollTimerRef = useRef(null);
+ const isPollingRef = useRef(false);
+
+ // Load workflows for this instance once
+ useEffect(() => {
+ if (!instanceId) return;
+ const _load = async () => {
+ setWorkflowsLoading(true);
+ try {
+ const res = await api.get(`/api/workflows/${instanceId}/workflows`);
+ const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
+ id: w.id,
+ label: w.label,
+ tags: w.tags || [],
+ }));
+ setWorkflows(items);
+ } catch {
+ setWorkflows([]);
+ } finally {
+ setWorkflowsLoading(false);
+ }
+ };
+ _load();
+ }, [instanceId]);
+
+ // Find the workflow for the active tab
+ const _findWorkflow = useCallback((tab: string): WorkflowSummary | undefined => {
+ const tabDef = _TABS.find((t) => t.id === tab);
+ if (!tabDef) return undefined;
+ return workflows.find((w) => w.tags.includes(tabDef.templateTag));
+ }, [workflows]);
+
+ // Polling
+ const _stopPolling = useCallback(() => {
+ if (pollTimerRef.current !== null) {
+ window.clearInterval(pollTimerRef.current);
+ pollTimerRef.current = null;
+ }
+ isPollingRef.current = false;
+ }, []);
+
+ const _pollRun = useCallback(async (rid: string) => {
+ if (!instanceId || !rid || isPollingRef.current) return;
+ isPollingRef.current = true;
+ try {
+ const res = await api.get(`/api/workflows/${instanceId}/runs/${rid}/steps`);
+ const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
+ const completed = steps.filter((s) => s.status === 'completed');
+ const failed = steps.filter((s) => s.status === 'failed');
+ const running = steps.filter((s) => s.status === 'running');
+
+ setRunSummary(`${completed.length}/${steps.length} ${t('trusteeAnalyse.stepsCompleted', 'Schritte abgeschlossen')}`);
+
+ if (failed.length > 0) {
+ const errMsg = failed[failed.length - 1].error || 'Step failed';
+ setRunState('error');
+ setRunError(errMsg);
+ _stopPolling();
+ showError('Pipeline error', errMsg);
+ return;
+ }
+ if (running.length === 0 && completed.length === steps.length && steps.length > 0) {
+ setRunState('completed');
+ _stopPolling();
+ showSuccess(t('trusteeAnalyse.completed', 'Abgeschlossen'), t('trusteeAnalyse.workflowDone', 'Analyse-Workflow erfolgreich beendet.'));
+ return;
+ }
+ setRunState('running');
+ } catch (err: any) {
+ if (err?.response?.status === 404) {
+ setRunState('running');
+ return;
+ }
+ setRunState('error');
+ setRunError(err.message || 'Polling failed');
+ _stopPolling();
+ } finally {
+ isPollingRef.current = false;
+ }
+ }, [instanceId, showError, showSuccess, _stopPolling, t]);
+
+ useEffect(() => {
+ if (!instanceId || !runId || (runState !== 'running' && runState !== 'starting')) return;
+ void _pollRun(runId);
+ pollTimerRef.current = window.setInterval(() => { void _pollRun(runId); }, 3000);
+ return () => { _stopPolling(); };
+ }, [instanceId, runId, runState, _pollRun, _stopPolling]);
+
+ useEffect(() => () => { _stopPolling(); }, [_stopPolling]);
+
+ // Reset run state when tab changes
+ useEffect(() => {
+ _stopPolling();
+ setRunState('idle');
+ setRunId(null);
+ setRunSummary('');
+ setRunError(null);
+ }, [activeTab, _stopPolling]);
+
+ // Execute workflow
+ const _handleExecute = useCallback(async () => {
+ const wf = _findWorkflow(activeTab);
+ if (!wf || !instanceId) {
+ showError('Error', t('trusteeAnalyse.noWorkflow', 'Kein Workflow für diesen Tab gefunden.'));
+ return;
+ }
+ setRunState('starting');
+ setRunError(null);
+ setRunSummary(t('trusteeAnalyse.starting', 'Workflow wird gestartet...'));
+ try {
+ const res = await api.post(`/api/workflows/${instanceId}/execute`, { workflowId: wf.id });
+ const rid = res?.data?.runId;
+ if (rid) {
+ setRunId(rid);
+ setRunState('running');
+ setRunSummary(`Run ${rid.slice(0, 8)} ${t('trusteeAnalyse.started', 'gestartet')}`);
+ } else if (res?.data?.success) {
+ setRunState('completed');
+ setRunSummary(t('trusteeAnalyse.completedSync', 'Workflow synchron abgeschlossen.'));
+ showSuccess(t('trusteeAnalyse.completed', 'Abgeschlossen'), t('trusteeAnalyse.workflowDone', 'Analyse-Workflow erfolgreich beendet.'));
+ } else {
+ throw new Error(res?.data?.error || 'Unexpected response');
+ }
+ } catch (err: any) {
+ const msg = err?.response?.data?.detail || err.message || 'Failed to start workflow';
+ setRunState('error');
+ setRunError(typeof msg === 'string' ? msg : JSON.stringify(msg));
+ showError('Error', typeof msg === 'string' ? msg : JSON.stringify(msg));
+ }
+ }, [activeTab, instanceId, _findWorkflow, showError, showSuccess, t]);
+
+ const currentTab = _TABS.find((t) => t.id === activeTab) || _TABS[0];
+ const currentWorkflow = _findWorkflow(activeTab);
+
+ return (
+
+
+
{t('trusteeAnalyse.title', 'Analyse & Reporting')}
+
+ {/* Tab bar */}
+
+ {_TABS.map((tab) => (
+ _setActiveTab(tab.id)}
+ style={{
+ padding: '0.625rem 1rem',
+ border: 'none',
+ borderBottom: activeTab === tab.id ? `3px solid ${tab.color}` : '3px solid transparent',
+ background: 'transparent',
+ color: activeTab === tab.id ? 'var(--text-primary, #1a1a1a)' : 'var(--text-secondary, #666)',
+ fontWeight: activeTab === tab.id ? 600 : 400,
+ fontSize: '0.875rem',
+ cursor: 'pointer',
+ transition: 'all 0.2s',
+ marginBottom: '-2px',
+ }}
+ >
+ {tab.icon}
+ {_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id}
+
+ ))}
+
+
+ {/* Tab content */}
+
+
+ {_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''}
+
+
+ {workflowsLoading ? (
+
{t('trusteeAnalyse.loadingWorkflows', 'Workflows werden geladen...')}
+ ) : !currentWorkflow ? (
+
+
{t('trusteeAnalyse.noWorkflowInfo', 'Für diesen Tab wurde kein Workflow in der Instanz gefunden. Der Workflow wird beim Erstellen der Instanz automatisch angelegt.')}
+
+ ) : (
+ <>
+
+
{currentTab.icon}
+
+
{currentWorkflow.label}
+
+ Workflow ID: {currentWorkflow.id.slice(0, 8)}...
+
+
+
+
+
+ {runState === 'starting' || runState === 'running'
+ ? t('trusteeAnalyse.running', 'Läuft...')
+ : t('trusteeAnalyse.execute', 'Ausführen')}
+
+ >
+ )}
+
+ {/* Pipeline status */}
+ {runState !== 'idle' && (
+
+
{t('trusteeAnalyse.status', 'Status')}: {' '}
+ {runState === 'starting' && t('trusteeAnalyse.starting', 'Wird gestartet...')}
+ {runState === 'running' && t('trusteeAnalyse.runningLabel', 'Läuft')}
+ {runState === 'completed' && t('trusteeAnalyse.completedLabel', 'Abgeschlossen')}
+ {runState === 'error' && t('trusteeAnalyse.errorLabel', 'Fehler')}
+ {runSummary &&
{runSummary}
}
+ {runError &&
{runError}
}
+
+ )}
+
+
+
+ );
+};
+
+export default TrusteeAnalyseView;
diff --git a/src/pages/views/trustee/TrusteeDashboardView.tsx b/src/pages/views/trustee/TrusteeDashboardView.tsx
index d5c8651..d52856b 100644
--- a/src/pages/views/trustee/TrusteeDashboardView.tsx
+++ b/src/pages/views/trustee/TrusteeDashboardView.tsx
@@ -3,19 +3,30 @@
*
* Overview dashboard for a Trustee instance.
* Shows statistics about positions, documents, and accounting sync status.
+ * Includes a QuickActionBoard for one-click navigation to feature pages.
*/
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useCallback } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useTrusteePositions, useTrusteeDocuments } from '../../../hooks/useTrustee';
import { useApiRequest } from '../../../hooks/useApi';
-import { fetchAccountingConfig, fetchSyncStatus, type AccountingConfig, type AccountingSyncStatus } from '../../../api/trusteeApi';
+import {
+ fetchAccountingConfig,
+ fetchSyncStatus,
+ fetchQuickActions,
+ type AccountingConfig,
+ type AccountingSyncStatus,
+} from '../../../api/trusteeApi';
+import { QuickActionBoard, type QuickAction, type QuickActionCategory } from '../../../components/QuickActionBoard';
import styles from './TrusteeViews.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
export const TrusteeDashboardView: React.FC = () => {
- const { t } = useLanguage();
+ const { t, currentLanguage } = useLanguage();
+ const navigate = useNavigate();
+ const { mandateId } = useParams<{ mandateId: string }>();
const { instance, instanceId } = useCurrentInstance();
const { items: positions, loading: posLoading } = useTrusteePositions();
@@ -25,10 +36,13 @@ export const TrusteeDashboardView: React.FC = () => {
const [accountingConfig, setAccountingConfig] = useState(null);
const [syncItems, setSyncItems] = useState([]);
const [accountingLoading, setAccountingLoading] = useState(true);
+ const [quickActions, setQuickActions] = useState([]);
+ const [quickActionCategories, setQuickActionCategories] = useState([]);
+ const [quickActionsLoading, setQuickActionsLoading] = useState(true);
useEffect(() => {
if (!instanceId) return;
- const loadAccountingData = async () => {
+ const _loadAccountingData = async () => {
setAccountingLoading(true);
try {
const [config, syncData] = await Promise.all([
@@ -43,8 +57,35 @@ export const TrusteeDashboardView: React.FC = () => {
setAccountingLoading(false);
}
};
- loadAccountingData();
+ _loadAccountingData();
}, [instanceId, request]);
+
+ useEffect(() => {
+ if (!instanceId) return;
+ const _loadQuickActions = async () => {
+ setQuickActionsLoading(true);
+ try {
+ const result = await fetchQuickActions(request, instanceId, currentLanguage || 'de');
+ setQuickActions(result.actions || []);
+ setQuickActionCategories(result.categories || []);
+ } catch {
+ setQuickActions([]);
+ setQuickActionCategories([]);
+ } finally {
+ setQuickActionsLoading(false);
+ }
+ };
+ _loadQuickActions();
+ }, [instanceId, request, currentLanguage]);
+
+ const _handleDispatch = useCallback((action: QuickAction) => {
+ const targetView = action.config?.targetView;
+ if (targetView && mandateId && instanceId) {
+ const tab = action.config?.tab;
+ const path = `/mandates/${mandateId}/trustee/${instanceId}/${targetView}`;
+ navigate(tab ? `${path}?tab=${tab}` : path);
+ }
+ }, [navigate, mandateId, instanceId]);
const isLoading = posLoading || docsLoading || accountingLoading;
const syncedCount = syncItems.filter(s => s.syncStatus === 'synced').length;
@@ -105,6 +146,13 @@ export const TrusteeDashboardView: React.FC = () => {
+
+
Instanz-Details
diff --git a/src/pages/views/trustee/TrusteeInstanceRolesView.tsx b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx
index 1bf9983..4d324f9 100644
--- a/src/pages/views/trustee/TrusteeInstanceRolesView.tsx
+++ b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx
@@ -107,11 +107,8 @@ export const TrusteeInstanceRolesView: React.FC = () => {
-
-
- Instanz-Rollen & Berechtigungen
-
+
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz
diff --git a/src/pages/views/trustee/index.ts b/src/pages/views/trustee/index.ts
index 85705ff..a3d5ca6 100644
--- a/src/pages/views/trustee/index.ts
+++ b/src/pages/views/trustee/index.ts
@@ -9,3 +9,5 @@ export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
+export { TrusteeAnalyseView } from './TrusteeAnalyseView';
+export { TrusteeAbschlussView } from './TrusteeAbschlussView';
diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx
index 4b91cc8..d48ecb6 100644
--- a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx
+++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx
@@ -102,8 +102,6 @@ export const WorkspaceGeneralSettings: React.FC
= ({ insta
return (
-
{t('workspaceGeneralSettings.generelleEinstellungen')}
-
{error &&
{error}
}
{success &&
{success}
}
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index 84abd12..c79d641 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -8,7 +8,7 @@
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useFileOperations } from '../../../hooks/useFiles';
import { useWorkspace } from './useWorkspace';
@@ -90,6 +90,8 @@ export const WorkspacePage: React.FC
= ({ persistentInstance
const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
const fileInputRef = useRef(null);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const autoStartHandled = useRef(false);
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
);
@@ -112,6 +114,22 @@ export const WorkspacePage: React.FC = ({ persistentInstance
}
}, [isMobile]);
+ useEffect(() => {
+ if (autoStartHandled.current || !instanceId || workspace.isProcessing) return;
+ const prompt = searchParams.get('prompt');
+ const autoStart = searchParams.get('autoStart') === 'true';
+ if (prompt) {
+ autoStartHandled.current = true;
+ setSearchParams({}, { replace: true });
+ if (autoStart) {
+ const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders);
+ workspace.sendMessage(prompt, [], [], resolvedProviders, []);
+ } else {
+ setDraftAppend(prompt);
+ }
+ }
+ }, [instanceId, searchParams, setSearchParams, workspace, providerSelection, allowedProviders]);
+
const _uploadAndAttach = useCallback(async (file: File) => {
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
if (result.success && result.fileData) {