phase 2 i18n clean

This commit is contained in:
ValueOn AG 2026-04-10 12:33:32 +02:00
parent 095fe34c81
commit abe6ba60d4
54 changed files with 1249 additions and 3629 deletions

View file

@ -38,7 +38,7 @@ import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminLanguagesPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -155,6 +155,8 @@ function App() {
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
<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 */}
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
@ -203,7 +205,7 @@ function App() {
</Route>
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="languages" element={<AdminLanguagesPage />} />
<Route path="languages" element={null} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
</Route>

View file

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

View file

@ -86,6 +86,8 @@ export interface Automation2GraphNode {
id: string;
type: string;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
}
export interface Automation2Connection {

View file

@ -21,6 +21,8 @@ export interface CanvasNode {
inputs: number;
outputs: number;
parameters?: Record<string, unknown>;
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
outputPorts?: Array<{ name: string; schema: string }>;
}
export interface CanvasConnection {

View file

@ -9,4 +9,4 @@ export { CanvasHeader } from './editor/CanvasHeader';
export * from './nodes/shared/utils';
export * from './nodes/shared/constants';
export * from './nodes/shared/graphUtils';
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig';
export { getAcceptStringFromConfig } from './nodes/shared/utils';

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,32 +0,0 @@
/**
* Approval node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Titel</label>
<input
value={(params.title as string) ?? ''}
onChange={(e) => updateParam('title', e.target.value)}
placeholder="Genehmigungstitel"
/>
</div>
<div>
<label>{t('approvalNodeConfig.beschreibung')}</label>
<textarea
value={(params.description as string) ?? ''}
onChange={(e) => updateParam('description', e.target.value)}
placeholder={t('approvalNodeConfig.wasGenehmigtWerdenSoll')}
/>
</div>
</>
);
};

File diff suppressed because it is too large Load diff

View file

@ -1,34 +0,0 @@
/**
* Comment node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Platzhalter</label>
<input
value={(params.placeholder as string) ?? ''}
onChange={(e) => updateParam('placeholder', e.target.value)}
placeholder={t('commentNodeConfig.kommentarEingeben')}
/>
</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,38 +0,0 @@
/**
* Confirmation node config
*/
import React from 'react';
import type { NodeConfigRendererProps } from './types';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
const { t } = useLanguage();
return (
<>
<div>
<label>Frage</label>
<input
value={(params.question as string) ?? ''}
onChange={(e) => updateParam('question', e.target.value)}
placeholder={t('confirmationNodeConfig.moechtenSieBestaetigen')}
/>
</div>
<div>
<label>{t('confirmationNodeConfig.bestaetigenbutton')}</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,242 +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/workflowApi';
import { useLanguage } from '../../../../providers/language/LanguageContext';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'email.checkEmail',
}) => {
const { t } = useLanguage();
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 ? t('emailNodeConfig.loading') : t('emailNodeConfig.selectConnection')}</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 ? t('emailNodeConfig.selectAccountFirst') : t('emailNodeConfig.selectFolder')}
</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">{t('emailNodeConfig.sentItems')}</option>
<option value="DeletedItems">{t('emailNodeConfig.deletedItems')}</option>
<option value="JunkEmail">{t('emailNodeConfig.junkEmail')}</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>{t('emailNodeConfig.searchQueryOptional')}</label>
<input
value={(params.query as string) ?? ''}
onChange={(e) => updateParam('query', e.target.value)}
placeholder={t('emailNodeConfig.generalSearchTermSubjectBody')}
/>
</div>
<div>
<label>{t('emailNodeConfig.fromAddressOptional')}</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder={t('emailNodeConfig.egSenderexamplecom')}
/>
</div>
<div>
<label>{t('emailNodeConfig.toAddressOptional')}</label>
<input
value={(params.toAddress as string) ?? ''}
onChange={(e) => updateParam('toAddress', e.target.value)}
placeholder={t('emailNodeConfig.egRecipientexamplecom')}
/>
</div>
<div>
<label>{t('emailNodeConfig.subjectContainsOptional')}</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
/>
</div>
<div>
<label>{t('emailNodeConfig.bodycontentContainsOptional')}</label>
<input
value={(params.bodyContains as string) ?? ''}
onChange={(e) => updateParam('bodyContains', e.target.value)}
placeholder={t('emailNodeConfig.wordOrPhraseInEmail')}
/>
</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">{t('emailNodeConfig.onlyEmailsWithAttachment')}</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>{t('emailNodeConfig.fromAddressOptional')}</label>
<input
value={(params.fromAddress as string) ?? ''}
onChange={(e) => updateParam('fromAddress', e.target.value)}
placeholder={t('emailNodeConfig.egSenderexamplecom')}
/>
</div>
<div>
<label>{t('emailNodeConfig.subjectContainsOptional')}</label>
<input
value={(params.subjectContains as string) ?? ''}
onChange={(e) => updateParam('subjectContains', e.target.value)}
placeholder={t('emailNodeConfig.wordOrPhraseInSubject')}
/>
</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">{t('emailNodeConfig.onlyEmailsWithAttachment')}</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={t('emailNodeConfig.emailSubjectOrLeaveEmpty')}
/>
</div>
<div>
<label>Body</label>
<textarea
value={(params.body as string) ?? ''}
onChange={(e) => updateParam('body', e.target.value)}
placeholder={t('emailNodeConfig.emailBodyOrLeaveEmpty')}
rows={4}
/>
</div>
<div>
<label>{t('emailNodeConfig.toOptional')}</label>
<input
value={(params.to as string) ?? ''}
onChange={(e) => updateParam('to', e.target.value)}
placeholder={t('emailNodeConfig.recipientsOrFromAiWhen')}
/>
</div>
</>
)}
</>
);
};

View file

@ -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<NodeConfigRendererProps> = ({ 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 (
<>
<div className={styles.fileCreateContentSources}>
<label>{t('fileCreateNodeConfig.inhalteWelcheKontexteNacheinanderIn')}</label>
{contentSources.map((ref, i) => (
<div key={i} className={styles.contentSourceRow}>
<RefSourceSelect
value={ref}
onChange={(r) => setItem(i, r)}
placeholder={t('fileCreateNodeConfig.quelleWaehlen')}
/>
<button
type="button"
className={styles.contentSourceRemoveBtn}
onClick={() => removeItem(i)}
title={t('fileCreateNodeConfig.entfernen')}
aria-label={t('fileCreateNodeConfig.inhaltEntfernen')}
>
×
</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>{t('fileCreateNodeConfig.vorlageStil')}</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>{t('fileCreateNodeConfig.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,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<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'sharepoint.findFile',
}) => {
const { t } = useLanguage();
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 ? t('sharePointNodeConfig.loading') : t('sharePointNodeConfig.selectConnection')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
{needsSearch && (
<div>
<label>{t('sharePointNodeConfig.searchQueryPath')}</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>{t('sharePointNodeConfig.folderPath')}</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'
? t('sharePointNodeConfig.filePath')
: t('sharePointNodeConfig.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>{t('sharePointNodeConfig.siteId')}</label>
<input
value={(params.siteId as string) ?? ''}
onChange={(e) => updateParam('siteId', e.target.value)}
placeholder={t('sharePointNodeConfig.sharepointSiteId')}
/>
</div>
)}
{nodeType === 'sharepoint.copyFile' && (
<>
<div>
<label>{t('sharePointNodeConfig.sourceFile')}</label>
<input
value={(params.sourcePath as string) ?? ''}
onChange={(e) => updateParam('sourcePath', e.target.value)}
placeholder="/sites/.../folder/file.pdf"
/>
</div>
<div>
<label>{t('sharePointNodeConfig.destinationFolder')}</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,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<NodeConfigRendererProps> = ({ params,
updateParam,
instanceId,
request,
nodeType = 'trustee.extractFromFiles',
}) => {
const { t } = useLanguage();
const [connections, setConnections] = useState<UserConnection[]>([]);
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 (
<>
<div>
<label>{t('trusteeNodeConfig.trusteeInstanceId')}</label>
<input
value={(params.featureInstanceId as string) ?? ''}
onChange={(e) => updateParam('featureInstanceId', e.target.value)}
placeholder={t('trusteeNodeConfig.trusteeFeatureinstanzid')}
/>
</div>
{isExtract && (
<>
<div>
<label>{t('trusteeNodeConfig.sharepointConnectionOptional')}</label>
<select
value={(params.connectionId as string) ?? ''}
onChange={(e) => updateParam('connectionId', e.target.value)}
disabled={loading}
>
<option value="">{loading ? t('trusteeNodeConfig.laden') : t('trusteeNodeConfig.keineDateienAusVorherigemSchritt')}</option>
{connections.map((c) => (
<option key={c.id} value={c.id}>
{c.externalUsername ?? c.id}
</option>
))}
</select>
</div>
<div>
<label>{t('trusteeNodeConfig.sharepointOrdnerpfadOptional')}</label>
<input
value={(params.sharepointFolder as string) ?? ''}
onChange={(e) => updateParam('sharepointFolder', e.target.value)}
placeholder="/sites/MySite/Documents/Expenses"
/>
</div>
<div>
<label>{t('trusteeNodeConfig.aiPromptOptional')}</label>
<textarea
value={(params.prompt as string) ?? ''}
onChange={(e) => updateParam('prompt', e.target.value)}
placeholder={t('trusteeNodeConfig.zusaetzlicheAnweisungenFuerDieAiextraktion')}
rows={3}
/>
</div>
</>
)}
{!isExtract && (
<div>
<label>{t('trusteeNodeConfig.documentListReferenz')}</label>
<input
value={(params.documentList as string) ?? ''}
onChange={(e) => updateParam('documentList', e.target.value)}
placeholder={t('trusteeNodeConfig.referenzAufVorherigenSchrittAutomatisch')}
/>
</div>
)}
</>
);
};

View file

@ -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, unknown>): string {
const types = parseAllowedTypes(config);
return buildAcceptString(types);
}
const FILE_TYPE_CHIP_OPTIONS = getAcceptValues();
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ 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 (
<div className={styles.uploadNodeConfig}>
<div className={styles.configBlock}>
<label>{t('uploadNodeConfig.erlaubteDateitypen')}</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>{t('uploadNodeConfig.maxGroesseMb')}</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,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<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,
'trustee.extractFromFiles': TrusteeNodeConfig,
'trustee.processDocuments': TrusteeNodeConfig,
'trustee.syncToAccounting': TrusteeNodeConfig,
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
/** Function type for resolving localized labels */
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
export function getAcceptStringFromConfig(
config: Record<string, unknown>
): string {
const types = config.allowedTypes;
if (!Array.isArray(types) || types.length === 0) return '*';
return types.join(',');
}

View file

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

View file

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

View file

@ -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 = `{

View file

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

View file

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

View file

@ -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<T extends Record<string, any>>({
customValidator,
instanceId
}: FormGeneratorFormProps<T>) {
const { t } = useLanguage();
const { t, availableLanguages } = useLanguage();
const [formData, setFormData] = useState<T>(data || {} as T);
const [errors, setErrors] = useState<Record<string, string>>({});
@ -126,6 +123,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
const [translatingField, setTranslatingField] = useState<string | null>(null);
// Track which option keys have been fetched or are being fetched (using ref to avoid re-renders)
const fetchedOrFetchingOptions = useRef<Set<string>>(new Set());
@ -657,32 +655,68 @@ export function FormGeneratorForm<T extends Record<string, any>>({
}
};
// 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<string, string>) => {
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<string, string> = 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 (
@ -703,7 +737,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>
{languages.map(lang => (
{multilingualLangs.map(lang => (
<div className={styles.floatingLabelInput} key={lang.code}>
<input
type="text"
@ -714,11 +748,25 @@ export function FormGeneratorForm<T extends Record<string, any>>({
className={`${styles.fieldInput} ${hasError && lang.code === 'en' ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(`${attr.name}.${lang.code}`, multilingualValue[lang.code])}>
{lang.label}
{lang.uiLabel}
{lang.required && <span className={styles.required}>*</span>}
</label>
</div>
))}
{multilingualLangs.length > 1 && (
<button
type="button"
className={styles.translateBtn}
disabled={translatingField === attr.name || !multilingualLangs.some(l => (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
? <><span className={styles.translateBtnSpinner} /> {t('Übersetze…')}</>
: <>&#x1F310; {t('In alle Sprachen übersetzen')}</>
}
</button>
)}
{hasError && <span className={styles.errorText}>{hasError}</span>}
</div>
);

View file

@ -78,48 +78,29 @@ import api from '../../../api';
// FK Cache type: maps fkSource -> { id -> displayLabel }
type FkCacheType = Record<string, Record<string, string>>;
// 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<string, string> = {
'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];
}
}
@ -335,11 +316,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
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<string, string> = { '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<T extends Record<string, any>>({
// 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<string, string> = { '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

View file

@ -46,12 +46,13 @@ type NavTranslateFn = (key: string, params?: Record<string, string | number>) =>
// =============================================================================
/**
* 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 ? (
<button
className={styles.renameButton}
@ -206,7 +209,7 @@ const EmptyState: React.FC = () => {
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;

View file

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

View file

@ -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<string, any>;
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<string, string> = {
'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 (
<span className={styles.actionIcon} style={{ color }}>
{fallback}
</span>
);
}
// ============================================================================
// COMPONENT
// ============================================================================
export const QuickActionBoard: React.FC<QuickActionBoardProps> = ({
actions,
categories,
onDispatch,
loading = false,
grouped = true,
}) => {
const { t } = useLanguage();
if (loading) {
return (
<div className={styles.board}>
<h3 className={styles.boardTitle}>{t('quickActions.title')}</h3>
<div className={styles.grid}>
{[1, 2, 3, 4].map((i) => (
<div key={i} className={`${styles.card} ${styles.cardSkeleton}`}>
<div className={styles.skeletonIcon} />
<div className={styles.skeletonText} />
<div className={styles.skeletonTextShort} />
</div>
))}
</div>
</div>
);
}
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 (
<div className={styles.board}>
<h3 className={styles.boardTitle}>{t('quickActions.title')}</h3>
<div className={styles.grid}>
{actions.map((action) => (
<button
key={action.id}
className={styles.card}
onClick={_handleClick(action)}
title={action.description}
>
{_renderIcon(action.icon, action.color)}
<span className={styles.actionLabel}>{action.label}</span>
<span className={styles.actionDescription}>{action.description}</span>
</button>
))}
</div>
</div>
);
}
const sortedCategories = [...categories].sort((a, b) => a.sortOrder - b.sortOrder);
const actionsByCategory = new Map<string, QuickAction[]>();
for (const action of actions) {
const cat = action.category || '_uncategorized';
if (!actionsByCategory.has(cat)) actionsByCategory.set(cat, []);
actionsByCategory.get(cat)!.push(action);
}
return (
<div className={styles.board}>
<h3 className={styles.boardTitle}>{t('quickActions.title')}</h3>
{sortedCategories.map((cat) => {
const catActions = actionsByCategory.get(cat.id);
if (!catActions || catActions.length === 0) return null;
return (
<div key={cat.id} className={styles.categorySection}>
<h4 className={styles.categoryTitle}>{cat.label}</h4>
<div className={styles.grid}>
{catActions.map((action) => (
<button
key={action.id}
className={styles.card}
onClick={_handleClick(action)}
title={action.description}
>
{_renderIcon(action.icon, action.color)}
<span className={styles.actionLabel}>{action.label}</span>
<span className={styles.actionDescription}>{action.description}</span>
</button>
))}
</div>
</div>
);
})}
</div>
);
};
export default QuickActionBoard;

View file

@ -0,0 +1,2 @@
export { QuickActionBoard, default } from './QuickActionBoard';
export type { QuickAction, QuickActionCategory, QuickActionBoardProps } from './QuickActionBoard';

View file

@ -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<string, React.ReactNode> = {
'page.feature.trustee.scan-upload': <FaFileAlt />,
'page.feature.trustee.instance-roles': <FaUserShield />,
'page.feature.trustee.settings': <FaCog />,
'page.feature.trustee.analyse': <FaChartBar />,
'page.feature.trustee.abschluss': <FaClipboardCheck />,
// Feature pages - Real Estate
'page.feature.realestate.projects': <FaProjectDiagram />,

View file

@ -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<NavigationBlock[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<NavigationResponse>(
`/api/navigation?language=${language}`
);
// Blocks are already sorted by order from backend
const response = await api.get<NavigationResponse>('/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();

View file

@ -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 = () => {
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
<div
className={styles.outletShell}

View file

@ -8,9 +8,6 @@
import React from 'react';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { useNavigation } from '../hooks/useNavigation';
import type { FeatureView as FeatureViewDef } from '../hooks/useNavigation';
// Trustee Views
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
@ -20,6 +17,8 @@ import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesVi
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
// Chatbot Views
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
@ -128,6 +127,8 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
'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<FeatureViewPageProps> = ({ 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<FeatureViewPageProps> = ({ 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<FeatureViewPageProps> = ({ view }) => {
return <NotFound />;
}
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 (
<div className={styles.featureView}>
{!_skipViewHeader && (
<header className={styles.viewHeader}>
<h1 className={styles.viewTitle}>{viewLabel}</h1>
</header>
)}
<main className={styles.viewContent}>
<ViewComponent />
</main>

View file

@ -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<AdminLanguagesKeepAliveProps> = ({ isVisible }) => {
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<AdminLanguagesPage />
</div>
);
};
export default AdminLanguagesKeepAlive;

View file

@ -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<string | null>(null);
const [addCode, setAddCode] = useState('');
const [progress, setProgress] = useState<ProgressInfo | null>(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 = () => {
</header>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
<button type="button" className={styles.secondaryButton} onClick={_load} disabled={isBusy || loading} title={t('Daten neu laden')}>
<FaSync style={loading ? { animation: 'spin 1s linear infinite' } : undefined} />
</button>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('Suche…')}
style={{ padding: '0.35rem 0.5rem', minWidth: 140, maxWidth: 200 }}
/>
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
{t('Alle aktualisieren')}
</button>
@ -688,11 +717,12 @@ export const AdminLanguagesPage: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
<FormGeneratorTable
data={rows}
data={displayRows}
columns={_getColumns(t)}
loading={loading}
pagination={false}
selectable={false}
searchable={false}
customActions={[
{
id: 'sync-xx',

View file

@ -132,9 +132,8 @@ export const ChatbotConversationsView: React.FC = () => {
return (
<div className={styles.chatbotView}>
{/* Chat History Sidebar */}
<aside className={styles.chatHistory}>
<div className={styles.chatHistoryHeader}>
<h2 className={styles.chatHistoryTitle}>Konversationen</h2>
<aside className={styles.chatHistory} aria-label="Konversationen">
<div className={styles.chatHistoryHeader} style={{ justifyContent: 'flex-end' }}>
<button
className={styles.newChatButton}
onClick={createNewThread}

View file

@ -81,8 +81,6 @@ export const CommcoachSettingsView: React.FC = () => {
return (
<div className={styles.settings}>
<h2 className={styles.heading}>Coaching-Einstellungen</h2>
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}

View file

@ -102,7 +102,6 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
if (!instanceId) {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
<h2>{t('graphicalEditor.graphicalEditor')}</h2>
<p>{t('graphicalEditor.keineFeatureinstanzGefunden')}</p>
</div>
);

View file

@ -218,7 +218,6 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Workflow-Vorlagen</h1>
<p className={styles.pageSubtitle}>
Vorlagen verwalten, kopieren und freigeben
</p>

View file

@ -257,7 +257,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('graphicalEditorWorkflows.gespeicherteWorkflows')}</h1>
<p className={styles.pageSubtitle}>
Workflows verwalten, ausführen und bearbeiten
</p>

View file

@ -190,7 +190,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
if (!instanceId) {
return (
<div className={styles.placeholder}>
<h2>Tasks</h2>
<p>{t('graphicalEditorWorkflowsTasks.keineFeatureinstanzGefunden')}</p>
</div>
);
@ -209,8 +208,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
<div className={styles.pageLayout}>
<div className={styles.mainColumn}>
<div className={styles.container}>
<h2>Tasks</h2>
{/* Open tasks */}
<section className={styles.section}>
<h3 className={styles.sectionTitle}>

View file

@ -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<string, Record<string, string>> = {
'year-end': { de: 'Jahresabschluss prüfen', en: 'Year-End Review', fr: 'Contrôle de clôture' },
};
const _TAB_DESCRIPTIONS: Record<string, Record<string, string>> = {
'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<WorkflowSummary[]>([]);
const [workflowsLoading, setWorkflowsLoading] = useState(true);
const [runState, setRunState] = useState<RunState>('idle');
const [runId, setRunId] = useState<string | null>(null);
const [runSummary, setRunSummary] = useState('');
const [runError, setRunError] = useState<string | null>(null);
const pollTimerRef = useRef<number | null>(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 (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('trusteeAbschluss.title', 'Abschluss & Prüfung')}</h3>
{/* Tab bar */}
{_TABS.length > 1 && (
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
{_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => _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',
}}
>
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id}
</button>
))}
</div>
)}
{/* Tab content */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p className={styles.sectionDescription}>
{_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''}
</p>
{workflowsLoading ? (
<p className={styles.loadingText}>{t('trusteeAbschluss.loadingWorkflows', 'Workflows werden geladen...')}</p>
) : !currentWorkflow ? (
<div className={styles.infoBox}>
<p>{t('trusteeAbschluss.noWorkflowInfo', 'Für diesen Tab wurde kein Workflow in der Instanz gefunden. Der Workflow wird beim Erstellen der Instanz automatisch angelegt.')}</p>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: '2rem' }}>{currentTab.icon}</span>
<div>
<div style={{ fontWeight: 600, color: 'var(--text-primary, #1a1a1a)' }}>{currentWorkflow.label}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-secondary, #666)' }}>
Workflow ID: {currentWorkflow.id.slice(0, 8)}...
</div>
</div>
</div>
<button
className={styles.primaryButton}
onClick={_handleExecute}
disabled={runState === 'starting' || runState === 'running'}
style={{ alignSelf: 'flex-start' }}
>
{runState === 'starting' || runState === 'running'
? t('trusteeAbschluss.running', 'Läuft...')
: t('trusteeAbschluss.execute', 'Prüfung starten')}
</button>
</>
)}
{runState !== 'idle' && (
<div className={runState === 'error' ? styles.errorMessage : styles.successMessage}>
<strong>{t('trusteeAbschluss.status', 'Status')}:</strong>{' '}
{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 && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
</div>
)}
</div>
</div>
</div>
);
};
export default TrusteeAbschlussView;

View file

@ -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<string, Record<string, string>> = {
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<string, Record<string, string>> = {
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<WorkflowSummary[]>([]);
const [workflowsLoading, setWorkflowsLoading] = useState(true);
const [runState, setRunState] = useState<RunState>('idle');
const [runId, setRunId] = useState<string | null>(null);
const [runSummary, setRunSummary] = useState('');
const [runError, setRunError] = useState<string | null>(null);
const pollTimerRef = useRef<number | null>(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 (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>{t('trusteeAnalyse.title', 'Analyse & Reporting')}</h3>
{/* Tab bar */}
<div style={{ display: 'flex', gap: '0.25rem', marginBottom: '1.5rem', borderBottom: '2px solid var(--border-color, #e0e0e0)', paddingBottom: 0 }}>
{_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => _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',
}}
>
<span style={{ marginRight: '0.375rem' }}>{tab.icon}</span>
{_TAB_LABELS[tab.id]?.[lang] || _TAB_LABELS[tab.id]?.de || tab.id}
</button>
))}
</div>
{/* Tab content */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<p className={styles.sectionDescription}>
{_TAB_DESCRIPTIONS[activeTab]?.[lang] || _TAB_DESCRIPTIONS[activeTab]?.de || ''}
</p>
{workflowsLoading ? (
<p className={styles.loadingText}>{t('trusteeAnalyse.loadingWorkflows', 'Workflows werden geladen...')}</p>
) : !currentWorkflow ? (
<div className={styles.infoBox}>
<p>{t('trusteeAnalyse.noWorkflowInfo', 'Für diesen Tab wurde kein Workflow in der Instanz gefunden. Der Workflow wird beim Erstellen der Instanz automatisch angelegt.')}</p>
</div>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span style={{ fontSize: '2rem' }}>{currentTab.icon}</span>
<div>
<div style={{ fontWeight: 600, color: 'var(--text-primary, #1a1a1a)' }}>{currentWorkflow.label}</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--text-secondary, #666)' }}>
Workflow ID: {currentWorkflow.id.slice(0, 8)}...
</div>
</div>
</div>
<button
className={styles.primaryButton}
onClick={_handleExecute}
disabled={runState === 'starting' || runState === 'running'}
style={{ alignSelf: 'flex-start' }}
>
{runState === 'starting' || runState === 'running'
? t('trusteeAnalyse.running', 'Läuft...')
: t('trusteeAnalyse.execute', 'Ausführen')}
</button>
</>
)}
{/* Pipeline status */}
{runState !== 'idle' && (
<div className={runState === 'error' ? styles.errorMessage : styles.successMessage}>
<strong>{t('trusteeAnalyse.status', 'Status')}:</strong>{' '}
{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 && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
</div>
)}
</div>
</div>
</div>
);
};
export default TrusteeAnalyseView;

View file

@ -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<AccountingConfig | null>(null);
const [syncItems, setSyncItems] = useState<AccountingSyncStatus[]>([]);
const [accountingLoading, setAccountingLoading] = useState(true);
const [quickActions, setQuickActions] = useState<QuickAction[]>([]);
const [quickActionCategories, setQuickActionCategories] = useState<QuickActionCategory[]>([]);
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,9 +57,36 @@ 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;
const syncErrorCount = syncItems.filter(s => s.syncStatus === 'error').length;
@ -106,6 +147,13 @@ export const TrusteeDashboardView: React.FC = () => {
</div>
</div>
<QuickActionBoard
actions={quickActions}
categories={quickActionCategories}
onDispatch={_handleDispatch}
loading={quickActionsLoading}
/>
<div className={styles.infoSection}>
<h3>Instanz-Details</h3>
<div className={styles.infoGrid}>

View file

@ -107,11 +107,8 @@ export const TrusteeInstanceRolesView: React.FC = () => {
<div className={styles.viewContainer}>
<div className={styles.viewHeader}>
<div className={styles.headerLeft}>
<h2 className={styles.viewTitle}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
Instanz-Rollen & Berechtigungen
</h2>
<p className={styles.viewSubtitle}>
<FaUserShield style={{ marginRight: '0.5rem', verticalAlign: 'middle' }} />
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz
</p>
</div>

View file

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

View file

@ -102,8 +102,6 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
return (
<div className={styles.settings}>
<h2 className={styles.heading}>{t('workspaceGeneralSettings.generelleEinstellungen')}</h2>
{error && <div className={styles.error}>{error}</div>}
{success && <div className={styles.success}>{success}</div>}

View file

@ -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<WorkspacePageProps> = ({ persistentInstance
const [draftAppend, setDraftAppend] = useState('');
const dragCounterRef = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const [searchParams, setSearchParams] = useSearchParams();
const autoStartHandled = useRef(false);
const [isMobile, setIsMobile] = useState<boolean>(() =>
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
);
@ -112,6 +114,22 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ 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) {