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 { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -155,6 +155,8 @@ function App() {
|
||||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||||
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
<Route path="scan-upload" element={<FeatureViewPage view="scan-upload" />} />
|
||||||
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
|
<Route path="analyse" element={<FeatureViewPage view="analyse" />} />
|
||||||
|
<Route path="abschluss" element={<FeatureViewPage view="abschluss" />} />
|
||||||
|
|
||||||
{/* Automation Feature Views */}
|
{/* Automation Feature Views */}
|
||||||
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
<Route path="definitions" element={<FeatureViewPage view="definitions" />} />
|
||||||
|
|
@ -203,7 +205,7 @@ function App() {
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="languages" element={<AdminLanguagesPage />} />
|
<Route path="languages" element={null} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
||||||
|
|
@ -751,6 +751,41 @@ export async function deletePositionDocument(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// QUICK ACTIONS API
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface QuickActionResponse {
|
||||||
|
actions: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
category: string;
|
||||||
|
actionType: 'agentPrompt' | 'workflow' | 'link';
|
||||||
|
config: Record<string, any>;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
|
categories: Array<{
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQuickActions(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
language: string = 'de'
|
||||||
|
): Promise<QuickActionResponse> {
|
||||||
|
return await request({
|
||||||
|
url: `${_getTrusteeBaseUrl(instanceId)}/quick-actions`,
|
||||||
|
method: 'get',
|
||||||
|
params: { language }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// ACCOUNTING API
|
// ACCOUNTING API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ export interface Automation2GraphNode {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
|
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||||
|
outputPorts?: Array<{ name: string; schema: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Automation2Connection {
|
export interface Automation2Connection {
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ export interface CanvasNode {
|
||||||
inputs: number;
|
inputs: number;
|
||||||
outputs: number;
|
outputs: number;
|
||||||
parameters?: Record<string, unknown>;
|
parameters?: Record<string, unknown>;
|
||||||
|
inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>;
|
||||||
|
outputPorts?: Array<{ name: string; schema: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CanvasConnection {
|
export interface CanvasConnection {
|
||||||
|
|
|
||||||
|
|
@ -9,4 +9,4 @@ export { CanvasHeader } from './editor/CanvasHeader';
|
||||||
export * from './nodes/shared/utils';
|
export * from './nodes/shared/utils';
|
||||||
export * from './nodes/shared/constants';
|
export * from './nodes/shared/constants';
|
||||||
export * from './nodes/shared/graphUtils';
|
export * from './nodes/shared/graphUtils';
|
||||||
export { getAcceptStringFromConfig } from './nodes/configs/UploadNodeConfig';
|
export { getAcceptStringFromConfig } from './nodes/shared/utils';
|
||||||
|
|
|
||||||
|
|
@ -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 React, { useEffect, useState } from 'react';
|
||||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../configs/types';
|
import type { FormField, NodeConfigRendererProps } from '../shared/types';
|
||||||
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import { isRef } from '../shared/dataRef';
|
import { isRef } from '../shared/dataRef';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
|
import { LoopItemsSelect } from '../shared/LoopItemsSelect';
|
||||||
import { createValue, isRef, isValue } from '../shared/dataRef';
|
import { createValue, isRef, isValue } from '../shared/dataRef';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export function fromApiGraph(
|
||||||
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
const cases = (n.parameters?.cases as unknown[]) ?? [];
|
||||||
outputs = Math.max(1, cases.length);
|
outputs = Math.max(1, cases.length);
|
||||||
}
|
}
|
||||||
|
const nt = nodeTypes.find((t) => t.id === n.type);
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
type: n.type,
|
type: n.type,
|
||||||
|
|
@ -37,6 +38,8 @@ export function fromApiGraph(
|
||||||
inputs: io.inputs,
|
inputs: io.inputs,
|
||||||
outputs,
|
outputs,
|
||||||
parameters: n.parameters ?? {},
|
parameters: n.parameters ?? {},
|
||||||
|
inputPorts: nt?.inputPorts,
|
||||||
|
outputPorts: nt?.outputPorts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -71,6 +74,8 @@ export function toApiGraph(
|
||||||
title: n.title,
|
title: n.title,
|
||||||
comment: n.comment,
|
comment: n.comment,
|
||||||
parameters: n.parameters ?? {},
|
parameters: n.parameters ?? {},
|
||||||
|
inputPorts: n.inputPorts,
|
||||||
|
outputPorts: n.outputPorts,
|
||||||
})),
|
})),
|
||||||
connections: connections.map((c) => {
|
connections: connections.map((c) => {
|
||||||
const srcNode = nodeMap.get(c.sourceId);
|
const srcNode = nodeMap.get(c.sourceId);
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,12 @@ export function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||||
|
|
||||||
/** Function type for resolving localized labels */
|
/** Function type for resolving localized labels */
|
||||||
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||||
|
|
||||||
|
/** Build an HTML accept attribute from an upload node config's allowedTypes array. */
|
||||||
|
export function getAcceptStringFromConfig(
|
||||||
|
config: Record<string, unknown>
|
||||||
|
): string {
|
||||||
|
const types = config.allowedTypes;
|
||||||
|
if (!Array.isArray(types) || types.length === 0) return '*';
|
||||||
|
return types.join(',');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from 'framer-motion';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import {
|
import {
|
||||||
type ScheduleSpec,
|
type ScheduleSpec,
|
||||||
type ScheduleMode,
|
type ScheduleMode,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import styles from '../../editor/Automation2FlowEditor.module.css';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const SCHEMA_EXAMPLE = `{
|
const SCHEMA_EXAMPLE = `{
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeConfigRendererProps } from '../configs/types';
|
import type { NodeConfigRendererProps } from '../shared/types';
|
||||||
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
import { RefSourceSelect, getFieldType } from '../shared/RefSourceSelect';
|
||||||
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
|
||||||
import { isRef, createValue } from '../shared/dataRef';
|
import { isRef, createValue } from '../shared/dataRef';
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,45 @@
|
||||||
box-shadow: none;
|
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 */
|
/* Responsive design */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.buttonGroup {
|
.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 { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import styles from './FormGeneratorForm.module.css';
|
import styles from './FormGeneratorForm.module.css';
|
||||||
|
|
@ -16,13 +16,10 @@ import {
|
||||||
} from '../../../utils/attributeTypeMapper';
|
} from '../../../utils/attributeTypeMapper';
|
||||||
import type { AttributeType } 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 => {
|
const isTextMultilingual = (value: any): boolean => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if it has 'en' property (required) and optionally other language codes
|
|
||||||
return 'en' in value && typeof value.en === 'string';
|
return 'en' in value && typeof value.en === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -116,7 +113,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
customValidator,
|
customValidator,
|
||||||
instanceId
|
instanceId
|
||||||
}: FormGeneratorFormProps<T>) {
|
}: FormGeneratorFormProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t, availableLanguages } = useLanguage();
|
||||||
|
|
||||||
const [formData, setFormData] = useState<T>(data || {} as T);
|
const [formData, setFormData] = useState<T>(data || {} as T);
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
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 [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
|
||||||
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
|
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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)
|
// Track which option keys have been fetched or are being fetched (using ref to avoid re-renders)
|
||||||
const fetchedOrFetchingOptions = useRef<Set<string>>(new Set());
|
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
|
// Render multilingual field
|
||||||
const renderMultilingualField = (attr: AttributeDefinition) => {
|
const renderMultilingualField = (attr: AttributeDefinition) => {
|
||||||
const value = formData[attr.name] || { en: '' };
|
const value = formData[attr.name] || { en: '' };
|
||||||
const hasError = errors[attr.name];
|
const hasError = errors[attr.name];
|
||||||
const isReadonly = mode === 'display' || attr.readonly || !isFieldEditableInMode(attr, mode);
|
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 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 handleMultilingualChange = (langCode: string, langValue: string) => {
|
||||||
const newValue = { ...multilingualValue, [langCode]: langValue };
|
const newValue = { ...multilingualValue, [langCode]: langValue };
|
||||||
handleFieldChange(attr.name, newValue);
|
handleFieldChange(attr.name, newValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isReadonly) {
|
if (isReadonly) {
|
||||||
// Display mode - show all languages
|
const displayValues = multilingualLangs
|
||||||
const displayValues = languages
|
|
||||||
.filter(lang => multilingualValue[lang.code] && multilingualValue[lang.code].trim())
|
.filter(lang => multilingualValue[lang.code] && multilingualValue[lang.code].trim())
|
||||||
.map(lang => `${lang.label}: ${multilingualValue[lang.code]}`)
|
.map(lang => `${lang.uiLabel}: ${multilingualValue[lang.code]}`)
|
||||||
.join(' | ');
|
.join(' | ');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fieldGroup} key={attr.name}>
|
<div className={styles.fieldGroup} key={attr.name}>
|
||||||
<div className={styles.readonlyField}>
|
<div className={styles.readonlyField}>
|
||||||
|
|
@ -696,14 +730,14 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.fieldGroup} key={attr.name}>
|
<div className={styles.fieldGroup} key={attr.name}>
|
||||||
<label className={styles.fieldLabel}>
|
<label className={styles.fieldLabel}>
|
||||||
{attr.label}
|
{attr.label}
|
||||||
{attr.required && <span className={styles.required}>*</span>}
|
{attr.required && <span className={styles.required}>*</span>}
|
||||||
</label>
|
</label>
|
||||||
{languages.map(lang => (
|
{multilingualLangs.map(lang => (
|
||||||
<div className={styles.floatingLabelInput} key={lang.code}>
|
<div className={styles.floatingLabelInput} key={lang.code}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -714,11 +748,25 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
className={`${styles.fieldInput} ${hasError && lang.code === 'en' ? styles.fieldError : ''}`}
|
className={`${styles.fieldInput} ${hasError && lang.code === 'en' ? styles.fieldError : ''}`}
|
||||||
/>
|
/>
|
||||||
<label className={getLabelClass(`${attr.name}.${lang.code}`, multilingualValue[lang.code])}>
|
<label className={getLabelClass(`${attr.name}.${lang.code}`, multilingualValue[lang.code])}>
|
||||||
{lang.label}
|
{lang.uiLabel}
|
||||||
{lang.required && <span className={styles.required}>*</span>}
|
{lang.required && <span className={styles.required}>*</span>}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>}
|
{hasError && <span className={styles.errorText}>{hasError}</span>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -78,51 +78,32 @@ import api from '../../../api';
|
||||||
// FK Cache type: maps fkSource -> { id -> displayLabel }
|
// FK Cache type: maps fkSource -> { id -> displayLabel }
|
||||||
type FkCacheType = Record<string, Record<string, string>>;
|
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 => {
|
const isTextMultilingual = (value: any): boolean => {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if it has 'en' property (required) and optionally other language codes
|
|
||||||
return 'en' in value && typeof value.en === 'string';
|
return 'en' in value && typeof value.en === 'string';
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to format TextMultilingual for display
|
|
||||||
const formatTextMultilingual = (value: any, currentLanguage?: string): string => {
|
const formatTextMultilingual = (value: any, currentLanguage?: string): string => {
|
||||||
if (!isTextMultilingual(value)) {
|
if (!isTextMultilingual(value)) {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map language codes (backend uses 'ge' for German, frontend might use 'de')
|
if (currentLanguage && value[currentLanguage] && typeof value[currentLanguage] === 'string' && value[currentLanguage].trim()) {
|
||||||
const languageMap: Record<string, string> = {
|
return value[currentLanguage];
|
||||||
'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];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to English (required field)
|
|
||||||
if (value.en && typeof value.en === 'string' && value.en.trim()) {
|
if (value.en && typeof value.en === 'string' && value.en.trim()) {
|
||||||
return value.en;
|
return value.en;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no English, try other languages
|
for (const key of Object.keys(value)) {
|
||||||
const languages = ['ge', 'fr', 'it'];
|
if (key !== 'en' && value[key] && typeof value[key] === 'string' && value[key].trim()) {
|
||||||
for (const lang of languages) {
|
return value[key];
|
||||||
if (value[lang] && typeof value[lang] === 'string' && value[lang].trim()) {
|
|
||||||
return value[lang];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '-';
|
return '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -335,11 +316,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// 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);
|
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(() => contextLanguage || 'en', [contextLanguage]);
|
||||||
const currentLanguage = useMemo(() => {
|
|
||||||
const langMap: Record<string, string> = { 'de': 'ge', 'en': 'en', 'fr': 'fr', 'it': 'it' };
|
|
||||||
return langMap[contextLanguage] || contextLanguage || 'en';
|
|
||||||
}, [contextLanguage]);
|
|
||||||
// Use provided columns from Pydantic attribute definitions
|
// Use provided columns from Pydantic attribute definitions
|
||||||
// NO AUTO-DETECTION - columns must come from backend 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)
|
// 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)
|
// Object - check for TextMultilingual (has 'en' key)
|
||||||
if (typeof fieldValue === 'object' && fieldValue !== null) {
|
if (typeof fieldValue === 'object' && fieldValue !== null) {
|
||||||
// TextMultilingual: { en: "...", ge: "...", fr: "...", it: "..." }
|
|
||||||
if ('en' in fieldValue) {
|
if ('en' in fieldValue) {
|
||||||
// Map frontend language codes to backend codes
|
return formatTextMultilingual(fieldValue, language);
|
||||||
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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other objects → try to stringify
|
// 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 {
|
return {
|
||||||
id: item.objectKey,
|
id: item.objectKey,
|
||||||
label: item.uiLabel,
|
label: tr(item.uiLabel),
|
||||||
icon: getPageIcon(item.uiComponent),
|
icon: getPageIcon(item.uiComponent),
|
||||||
path: item.uiPath,
|
path: item.uiPath,
|
||||||
};
|
};
|
||||||
|
|
@ -65,23 +66,25 @@ function _staticItemsToTreeNode(
|
||||||
id: string,
|
id: string,
|
||||||
label: string,
|
label: string,
|
||||||
items: NavigationItem[],
|
items: NavigationItem[],
|
||||||
|
tr: NavTranslateFn,
|
||||||
defaultExpanded: boolean = true,
|
defaultExpanded: boolean = true,
|
||||||
): TreeNodeItem {
|
): TreeNodeItem {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label,
|
label,
|
||||||
children: items.map(navigationItemToTreeNode),
|
children: items.map(i => navigationItemToTreeNode(i, tr)),
|
||||||
defaultExpanded,
|
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 {
|
return {
|
||||||
id: view.objectKey,
|
id: view.objectKey,
|
||||||
label: view.uiLabel,
|
label: tr(view.uiLabel),
|
||||||
path: view.uiPath,
|
path: view.uiPath,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +101,7 @@ function featureInstanceToTreeNode(
|
||||||
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
|
onRename: ((instanceId: string, currentLabel: string) => void) | undefined,
|
||||||
tr: NavTranslateFn,
|
tr: NavTranslateFn,
|
||||||
): TreeNodeItem {
|
): TreeNodeItem {
|
||||||
const children = instance.views.map(featureViewToTreeNode);
|
const children = instance.views.map(v => featureViewToTreeNode(v, tr));
|
||||||
const renameAction = instance.isAdmin && onRename ? (
|
const renameAction = instance.isAdmin && onRename ? (
|
||||||
<button
|
<button
|
||||||
className={styles.renameButton}
|
className={styles.renameButton}
|
||||||
|
|
@ -206,7 +209,7 @@ const EmptyState: React.FC = () => {
|
||||||
|
|
||||||
export const MandateNavigation: React.FC = () => {
|
export const MandateNavigation: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { blocks, loading, refresh } = useNavigation('de');
|
const { blocks, loading, refresh } = useNavigation();
|
||||||
const { prompt, PromptDialog } = usePrompt();
|
const { prompt, PromptDialog } = usePrompt();
|
||||||
const { showWarning } = useToast();
|
const { showWarning } = useToast();
|
||||||
|
|
||||||
|
|
@ -249,14 +252,14 @@ export const MandateNavigation: React.FC = () => {
|
||||||
if (systemBlock) {
|
if (systemBlock) {
|
||||||
const children: TreeNodeItem[] = [];
|
const children: TreeNodeItem[] = [];
|
||||||
for (const item of systemBlock.items) {
|
for (const item of systemBlock.items) {
|
||||||
children.push(navigationItemToTreeNode(item));
|
children.push(navigationItemToTreeNode(item, t));
|
||||||
}
|
}
|
||||||
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
|
if (systemBlock.subgroups && systemBlock.subgroups.length > 0) {
|
||||||
for (const sg of systemBlock.subgroups) {
|
for (const sg of systemBlock.subgroups) {
|
||||||
children.push({
|
children.push({
|
||||||
id: sg.id,
|
id: sg.id,
|
||||||
label: sg.title,
|
label: t(sg.title),
|
||||||
children: sg.items.map(navigationItemToTreeNode),
|
children: sg.items.map(i => navigationItemToTreeNode(i, t)),
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -285,8 +288,8 @@ export const MandateNavigation: React.FC = () => {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
if (items.length > 0) items.push({ type: 'separator' });
|
||||||
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({
|
||||||
id: sg.id,
|
id: sg.id,
|
||||||
label: sg.title,
|
label: t(sg.title),
|
||||||
children: sg.items.map(navigationItemToTreeNode),
|
children: sg.items.map(i => navigationItemToTreeNode(i, t)),
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
}));
|
}));
|
||||||
items.push({
|
items.push({
|
||||||
|
|
@ -297,7 +300,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
});
|
});
|
||||||
} else if (adminItems.length > 0) {
|
} else if (adminItems.length > 0) {
|
||||||
if (items.length > 0) items.push({ type: 'separator' });
|
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;
|
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,
|
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
FaFileContract, FaRobot, FaGlobe,
|
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
|
||||||
} from 'react-icons/fa';
|
} 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.scan-upload': <FaFileAlt />,
|
||||||
'page.feature.trustee.instance-roles': <FaUserShield />,
|
'page.feature.trustee.instance-roles': <FaUserShield />,
|
||||||
'page.feature.trustee.settings': <FaCog />,
|
'page.feature.trustee.settings': <FaCog />,
|
||||||
|
'page.feature.trustee.analyse': <FaChartBar />,
|
||||||
|
'page.feature.trustee.abschluss': <FaClipboardCheck />,
|
||||||
|
|
||||||
// Feature pages - Real Estate
|
// Feature pages - Real Estate
|
||||||
'page.feature.realestate.projects': <FaProjectDiagram />,
|
'page.feature.realestate.projects': <FaProjectDiagram />,
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* useNavigation Hook
|
* useNavigation Hook
|
||||||
*
|
*
|
||||||
* Fetches the navigation structure from the new Navigation API.
|
* Fetches the navigation structure from the Navigation API.
|
||||||
* The backend provides a blocks-based structure with static and dynamic blocks.
|
* Backend provides blocks with German base texts as labels (i18n keys).
|
||||||
|
* The UI translates them via t().
|
||||||
*
|
*
|
||||||
* API: GET /api/navigation?language=de
|
* API: GET /api/navigation
|
||||||
*
|
|
||||||
* 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": [...] },
|
|
||||||
* ...
|
|
||||||
* ]
|
|
||||||
* }
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
@ -99,7 +90,6 @@ export type NavigationBlock = StaticBlock | DynamicBlock;
|
||||||
|
|
||||||
/** API Response structure */
|
/** API Response structure */
|
||||||
export interface NavigationResponse {
|
export interface NavigationResponse {
|
||||||
language: string;
|
|
||||||
blocks: NavigationBlock[];
|
blocks: NavigationBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +125,7 @@ function isDynamicBlock(block: NavigationBlock): block is DynamicBlock {
|
||||||
// HOOK
|
// HOOK
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export function useNavigation(language: string = 'de'): UseNavigationReturn {
|
export function useNavigation(): UseNavigationReturn {
|
||||||
const [blocks, setBlocks] = useState<NavigationBlock[]>([]);
|
const [blocks, setBlocks] = useState<NavigationBlock[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -145,14 +135,8 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// New API endpoint: /api/navigation (without /system prefix)
|
const response = await api.get<NavigationResponse>('/api/navigation');
|
||||||
const response = await api.get<NavigationResponse>(
|
|
||||||
`/api/navigation?language=${language}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// Blocks are already sorted by order from backend
|
|
||||||
setBlocks(response.data.blocks || []);
|
setBlocks(response.data.blocks || []);
|
||||||
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg = err instanceof Error
|
const errorMsg = err instanceof Error
|
||||||
? err.message
|
? err.message
|
||||||
|
|
@ -163,7 +147,7 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [language]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchNavigation();
|
fetchNavigation();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||||
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||||
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
||||||
|
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -20,6 +21,7 @@ import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
||||||
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
||||||
|
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// INNER LAYOUT (mit Zugriff auf Store)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
|
|
@ -34,7 +36,8 @@ const MainLayoutInner: React.FC = () => {
|
||||||
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
||||||
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
|
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
|
||||||
const isGEEditorKeepAliveVisible = _GE_EDITOR_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
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -120,6 +123,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
||||||
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
||||||
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
|
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
|
||||||
|
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
import { useNavigation } from '../hooks/useNavigation';
|
|
||||||
import type { FeatureView as FeatureViewDef } from '../hooks/useNavigation';
|
|
||||||
|
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||||
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
|
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
|
||||||
|
|
@ -20,6 +17,8 @@ import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesVi
|
||||||
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
||||||
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
|
import { TrusteeScanUploadView } from './views/trustee/TrusteeScanUploadView';
|
||||||
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
import { TrusteeAccountingSettingsView } from './views/trustee/TrusteeAccountingSettingsView';
|
||||||
|
import { TrusteeAnalyseView } from './views/trustee/TrusteeAnalyseView';
|
||||||
|
import { TrusteeAbschlussView } from './views/trustee/TrusteeAbschlussView';
|
||||||
|
|
||||||
// Chatbot Views
|
// Chatbot Views
|
||||||
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsView';
|
||||||
|
|
@ -128,6 +127,8 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
'expense-import': TrusteeExpenseImportView,
|
'expense-import': TrusteeExpenseImportView,
|
||||||
'scan-upload': TrusteeScanUploadView,
|
'scan-upload': TrusteeScanUploadView,
|
||||||
settings: TrusteeAccountingSettingsView,
|
settings: TrusteeAccountingSettingsView,
|
||||||
|
analyse: TrusteeAnalyseView,
|
||||||
|
abschluss: TrusteeAbschlussView,
|
||||||
},
|
},
|
||||||
chatworkflow: {
|
chatworkflow: {
|
||||||
dashboard: ChatworkflowDashboard,
|
dashboard: ChatworkflowDashboard,
|
||||||
|
|
@ -180,8 +181,7 @@ interface FeatureViewPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { instance, featureCode, instanceId, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
const { blocks } = useNavigation();
|
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
|
|
@ -232,9 +232,6 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GraphicalEditor sub-pages have their own headers with actions; skip the wrapper title.
|
|
||||||
const _skipViewHeader = featureCode === 'graphicalEditor';
|
|
||||||
|
|
||||||
// View-Komponente finden
|
// View-Komponente finden
|
||||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
if (!featureViews) {
|
if (!featureViews) {
|
||||||
|
|
@ -246,29 +243,8 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return <NotFound />;
|
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 (
|
return (
|
||||||
<div className={styles.featureView}>
|
<div className={styles.featureView}>
|
||||||
{!_skipViewHeader && (
|
|
||||||
<header className={styles.viewHeader}>
|
|
||||||
<h1 className={styles.viewTitle}>{viewLabel}</h1>
|
|
||||||
</header>
|
|
||||||
)}
|
|
||||||
<main className={styles.viewContent}>
|
<main className={styles.viewContent}>
|
||||||
<ViewComponent />
|
<ViewComponent />
|
||||||
</main>
|
</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;
|
label: string;
|
||||||
status: string;
|
status: string;
|
||||||
entriesCount: number;
|
entriesCount: number;
|
||||||
|
uiCount: number;
|
||||||
|
gatewayCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProgressInfo = {
|
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: '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: '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: '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 [error, setError] = useState<string | null>(null);
|
||||||
const [addCode, setAddCode] = useState('');
|
const [addCode, setAddCode] = useState('');
|
||||||
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
const [progress, setProgress] = useState<ProgressInfo | null>(null);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
const busyRef = useRef(false);
|
const busyRef = useRef(false);
|
||||||
|
|
||||||
const _load = useCallback(async () => {
|
const _load = useCallback(async () => {
|
||||||
|
|
@ -273,6 +278,8 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
label: r.label || r.code,
|
label: r.label || r.code,
|
||||||
status: r.status || '',
|
status: r.status || '',
|
||||||
entriesCount: r.entriesCount ?? 0,
|
entriesCount: r.entriesCount ?? 0,
|
||||||
|
uiCount: r.uiCount ?? 0,
|
||||||
|
gatewayCount: r.gatewayCount ?? 0,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -286,6 +293,18 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
_load();
|
_load();
|
||||||
}, [_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 existingCodes = useMemo(() => new Set(rows.map((r) => r.id)), [rows]);
|
||||||
|
|
||||||
const addChoices = useMemo(() => {
|
const addChoices = useMemo(() => {
|
||||||
|
|
@ -658,6 +677,16 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.75rem', alignItems: 'center' }}>
|
<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}>
|
<button type="button" className={styles.primaryButton} onClick={_updateAll} disabled={isBusy}>
|
||||||
{t('Alle aktualisieren')}
|
{t('Alle aktualisieren')}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -688,11 +717,12 @@ export const AdminLanguagesPage: React.FC = () => {
|
||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
<div style={{ flex: 1, minHeight: 0, position: 'relative' }}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
data={rows}
|
data={displayRows}
|
||||||
columns={_getColumns(t)}
|
columns={_getColumns(t)}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
|
searchable={false}
|
||||||
customActions={[
|
customActions={[
|
||||||
{
|
{
|
||||||
id: 'sync-xx',
|
id: 'sync-xx',
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,8 @@ export const ChatbotConversationsView: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.chatbotView}>
|
<div className={styles.chatbotView}>
|
||||||
{/* Chat History Sidebar */}
|
{/* Chat History Sidebar */}
|
||||||
<aside className={styles.chatHistory}>
|
<aside className={styles.chatHistory} aria-label="Konversationen">
|
||||||
<div className={styles.chatHistoryHeader}>
|
<div className={styles.chatHistoryHeader} style={{ justifyContent: 'flex-end' }}>
|
||||||
<h2 className={styles.chatHistoryTitle}>Konversationen</h2>
|
|
||||||
<button
|
<button
|
||||||
className={styles.newChatButton}
|
className={styles.newChatButton}
|
||||||
onClick={createNewThread}
|
onClick={createNewThread}
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,6 @@ export const CommcoachSettingsView: React.FC = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
<h2 className={styles.heading}>Coaching-Einstellungen</h2>
|
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
{success && <div className={styles.success}>{success}</div>}
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,6 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
<h2>{t('graphicalEditor.graphicalEditor')}</h2>
|
|
||||||
<p>{t('graphicalEditor.keineFeatureinstanzGefunden')}</p>
|
<p>{t('graphicalEditor.keineFeatureinstanzGefunden')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -218,7 +218,6 @@ export const GraphicalEditorTemplatesPage: React.FC = () => {
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>Workflow-Vorlagen</h1>
|
|
||||||
<p className={styles.pageSubtitle}>
|
<p className={styles.pageSubtitle}>
|
||||||
Vorlagen verwalten, kopieren und freigeben
|
Vorlagen verwalten, kopieren und freigeben
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,6 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
<div className={styles.pageHeader}>
|
<div className={styles.pageHeader}>
|
||||||
<div>
|
<div>
|
||||||
<h1 className={styles.pageTitle}>{t('graphicalEditorWorkflows.gespeicherteWorkflows')}</h1>
|
|
||||||
<p className={styles.pageSubtitle}>
|
<p className={styles.pageSubtitle}>
|
||||||
Workflows verwalten, ausführen und bearbeiten
|
Workflows verwalten, ausführen und bearbeiten
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -190,7 +190,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.placeholder}>
|
<div className={styles.placeholder}>
|
||||||
<h2>Tasks</h2>
|
|
||||||
<p>{t('graphicalEditorWorkflowsTasks.keineFeatureinstanzGefunden')}</p>
|
<p>{t('graphicalEditorWorkflowsTasks.keineFeatureinstanzGefunden')}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -209,8 +208,6 @@ export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||||||
<div className={styles.pageLayout}>
|
<div className={styles.pageLayout}>
|
||||||
<div className={styles.mainColumn}>
|
<div className={styles.mainColumn}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<h2>Tasks</h2>
|
|
||||||
|
|
||||||
{/* Open tasks */}
|
{/* Open tasks */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h3 className={styles.sectionTitle}>
|
<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.
|
* Overview dashboard for a Trustee instance.
|
||||||
* Shows statistics about positions, documents, and accounting sync status.
|
* 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 { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useTrusteePositions, useTrusteeDocuments } from '../../../hooks/useTrustee';
|
import { useTrusteePositions, useTrusteeDocuments } from '../../../hooks/useTrustee';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
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 styles from './TrusteeViews.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
|
||||||
export const TrusteeDashboardView: React.FC = () => {
|
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 { instance, instanceId } = useCurrentInstance();
|
||||||
const { items: positions, loading: posLoading } = useTrusteePositions();
|
const { items: positions, loading: posLoading } = useTrusteePositions();
|
||||||
|
|
@ -25,10 +36,13 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
const [accountingConfig, setAccountingConfig] = useState<AccountingConfig | null>(null);
|
const [accountingConfig, setAccountingConfig] = useState<AccountingConfig | null>(null);
|
||||||
const [syncItems, setSyncItems] = useState<AccountingSyncStatus[]>([]);
|
const [syncItems, setSyncItems] = useState<AccountingSyncStatus[]>([]);
|
||||||
const [accountingLoading, setAccountingLoading] = useState(true);
|
const [accountingLoading, setAccountingLoading] = useState(true);
|
||||||
|
const [quickActions, setQuickActions] = useState<QuickAction[]>([]);
|
||||||
|
const [quickActionCategories, setQuickActionCategories] = useState<QuickActionCategory[]>([]);
|
||||||
|
const [quickActionsLoading, setQuickActionsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
const loadAccountingData = async () => {
|
const _loadAccountingData = async () => {
|
||||||
setAccountingLoading(true);
|
setAccountingLoading(true);
|
||||||
try {
|
try {
|
||||||
const [config, syncData] = await Promise.all([
|
const [config, syncData] = await Promise.all([
|
||||||
|
|
@ -43,8 +57,35 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
setAccountingLoading(false);
|
setAccountingLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadAccountingData();
|
_loadAccountingData();
|
||||||
}, [instanceId, request]);
|
}, [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 isLoading = posLoading || docsLoading || accountingLoading;
|
||||||
const syncedCount = syncItems.filter(s => s.syncStatus === 'synced').length;
|
const syncedCount = syncItems.filter(s => s.syncStatus === 'synced').length;
|
||||||
|
|
@ -105,6 +146,13 @@ export const TrusteeDashboardView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QuickActionBoard
|
||||||
|
actions={quickActions}
|
||||||
|
categories={quickActionCategories}
|
||||||
|
onDispatch={_handleDispatch}
|
||||||
|
loading={quickActionsLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={styles.infoSection}>
|
<div className={styles.infoSection}>
|
||||||
<h3>Instanz-Details</h3>
|
<h3>Instanz-Details</h3>
|
||||||
|
|
|
||||||
|
|
@ -107,11 +107,8 @@ export const TrusteeInstanceRolesView: React.FC = () => {
|
||||||
<div className={styles.viewContainer}>
|
<div className={styles.viewContainer}>
|
||||||
<div className={styles.viewHeader}>
|
<div className={styles.viewHeader}>
|
||||||
<div className={styles.headerLeft}>
|
<div className={styles.headerLeft}>
|
||||||
<h2 className={styles.viewTitle}>
|
|
||||||
<FaUserShield style={{ marginRight: '0.5rem' }} />
|
|
||||||
Instanz-Rollen & Berechtigungen
|
|
||||||
</h2>
|
|
||||||
<p className={styles.viewSubtitle}>
|
<p className={styles.viewSubtitle}>
|
||||||
|
<FaUserShield style={{ marginRight: '0.5rem', verticalAlign: 'middle' }} />
|
||||||
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz
|
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,5 @@ export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||||
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
export { TrusteeScanUploadView } from './TrusteeScanUploadView';
|
||||||
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
export { TrusteeAccountingSettingsView } from './TrusteeAccountingSettingsView';
|
||||||
|
export { TrusteeAnalyseView } from './TrusteeAnalyseView';
|
||||||
|
export { TrusteeAbschlussView } from './TrusteeAbschlussView';
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,6 @@ export const WorkspaceGeneralSettings: React.FC<GeneralSettingsProps> = ({ insta
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
<h2 className={styles.heading}>{t('workspaceGeneralSettings.generelleEinstellungen')}</h2>
|
|
||||||
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
{success && <div className={styles.success}>{success}</div>}
|
{success && <div className={styles.success}>{success}</div>}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
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 { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useFileOperations } from '../../../hooks/useFiles';
|
import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import { useWorkspace } from './useWorkspace';
|
import { useWorkspace } from './useWorkspace';
|
||||||
|
|
@ -90,6 +90,8 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
const [draftAppend, setDraftAppend] = useState('');
|
const [draftAppend, setDraftAppend] = useState('');
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const autoStartHandled = useRef(false);
|
||||||
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
const [isMobile, setIsMobile] = useState<boolean>(() =>
|
||||||
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
|
typeof window !== 'undefined' ? window.innerWidth <= 1024 : false,
|
||||||
);
|
);
|
||||||
|
|
@ -112,6 +114,22 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
|
||||||
}
|
}
|
||||||
}, [isMobile]);
|
}, [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 _uploadAndAttach = useCallback(async (file: File) => {
|
||||||
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
const result = await fileOps.handleFileUpload(file, undefined, instanceId);
|
||||||
if (result.success && result.fileData) {
|
if (result.success && result.fileData) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue