phase 2 i18n clean
This commit is contained in:
parent
095fe34c81
commit
abe6ba60d4
54 changed files with 1249 additions and 3629 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}}"
|
||||
/>
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -1 +0,0 @@
|
|||
export type { NodeConfigRendererProps, FormField } from '../shared/types';
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(',');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = `{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,34 +655,70 @@ 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 (
|
||||
<div className={styles.fieldGroup} key={attr.name}>
|
||||
<div className={styles.readonlyField}>
|
||||
|
|
@ -696,14 +730,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={styles.fieldGroup} key={attr.name}>
|
||||
<label className={styles.fieldLabel}>
|
||||
{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…')}</>
|
||||
: <>🌐 {t('In alle Sprachen übersetzen')}</>
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -78,51 +78,32 @@ 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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return '-';
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
118
src/components/QuickActionBoard/QuickActionBoard.module.css
Normal file
118
src/components/QuickActionBoard/QuickActionBoard.module.css
Normal 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; }
|
||||
}
|
||||
166
src/components/QuickActionBoard/QuickActionBoard.tsx
Normal file
166
src/components/QuickActionBoard/QuickActionBoard.tsx
Normal 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;
|
||||
2
src/components/QuickActionBoard/index.ts
Normal file
2
src/components/QuickActionBoard/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { QuickActionBoard, default } from './QuickActionBoard';
|
||||
export type { QuickAction, QuickActionCategory, QuickActionBoardProps } from './QuickActionBoard';
|
||||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
35
src/pages/admin/AdminLanguagesKeepAlive.tsx
Normal file
35
src/pages/admin/AdminLanguagesKeepAlive.tsx
Normal 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;
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
291
src/pages/views/trustee/TrusteeAbschlussView.tsx
Normal file
291
src/pages/views/trustee/TrusteeAbschlussView.tsx
Normal 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;
|
||||
304
src/pages/views/trustee/TrusteeAnalyseView.tsx
Normal file
304
src/pages/views/trustee/TrusteeAnalyseView.tsx
Normal 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;
|
||||
|
|
@ -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,8 +57,35 @@ export const TrusteeDashboardView: React.FC = () => {
|
|||
setAccountingLoading(false);
|
||||
}
|
||||
};
|
||||
loadAccountingData();
|
||||
_loadAccountingData();
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
const _loadQuickActions = async () => {
|
||||
setQuickActionsLoading(true);
|
||||
try {
|
||||
const result = await fetchQuickActions(request, instanceId, currentLanguage || 'de');
|
||||
setQuickActions(result.actions || []);
|
||||
setQuickActionCategories(result.categories || []);
|
||||
} catch {
|
||||
setQuickActions([]);
|
||||
setQuickActionCategories([]);
|
||||
} finally {
|
||||
setQuickActionsLoading(false);
|
||||
}
|
||||
};
|
||||
_loadQuickActions();
|
||||
}, [instanceId, request, currentLanguage]);
|
||||
|
||||
const _handleDispatch = useCallback((action: QuickAction) => {
|
||||
const targetView = action.config?.targetView;
|
||||
if (targetView && mandateId && instanceId) {
|
||||
const tab = action.config?.tab;
|
||||
const path = `/mandates/${mandateId}/trustee/${instanceId}/${targetView}`;
|
||||
navigate(tab ? `${path}?tab=${tab}` : path);
|
||||
}
|
||||
}, [navigate, mandateId, instanceId]);
|
||||
|
||||
const isLoading = posLoading || docsLoading || accountingLoading;
|
||||
const syncedCount = syncItems.filter(s => s.syncStatus === 'synced').length;
|
||||
|
|
@ -105,6 +146,13 @@ export const TrusteeDashboardView: React.FC = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickActionBoard
|
||||
actions={quickActions}
|
||||
categories={quickActionCategories}
|
||||
onDispatch={_handleDispatch}
|
||||
loading={quickActionsLoading}
|
||||
/>
|
||||
|
||||
<div className={styles.infoSection}>
|
||||
<h3>Instanz-Details</h3>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue